Files
Sportstime/SportsTime/Export/Services/RemoteImageService.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

182 lines
5.2 KiB
Swift

//
// RemoteImageService.swift
// SportsTime
//
// Downloads and caches remote images for PDF export (team logos, stadium photos).
//
import Foundation
import UIKit
import LRUCache
actor RemoteImageService {
// MARK: - Errors
enum ImageFetchError: Error, LocalizedError {
case invalidURL
case downloadFailed(String)
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid image URL"
case .downloadFailed(let reason):
return "Image download failed: \(reason)"
case .invalidImageData:
return "Downloaded data is not a valid image"
}
}
}
// MARK: - Properties
private let urlSession: URLSession
private let imageCache = LRUCache<URL, UIImage>(countLimit: 50)
// MARK: - Init
init() {
// Configure URLSession with caching
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
config.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
diskCapacity: 100 * 1024 * 1024, // 100 MB disk
diskPath: "ImageCache"
)
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
self.urlSession = URLSession(configuration: config)
}
// MARK: - Public Methods
/// Fetch a single image from URL
func fetchImage(from url: URL) async throws -> UIImage {
// Check cache first
if let cached = imageCache.value(forKey: url) {
return cached
}
// Download image
let (data, response) = try await urlSession.data(from: url)
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw ImageFetchError.downloadFailed("HTTP \(statusCode)")
}
// Create image
guard let image = UIImage(data: data) else {
throw ImageFetchError.invalidImageData
}
// Scale image for PDF (max 400pt width to keep file size reasonable)
let scaledImage = scaleImage(image, maxWidth: 400)
// Cache it
imageCache.setValue(scaledImage, forKey: url)
return scaledImage
}
/// Batch fetch multiple images in parallel
func fetchImages(from urls: [URL]) async -> [URL: UIImage] {
var results: [URL: UIImage] = [:]
await withTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls {
group.addTask {
do {
let image = try await self.fetchImage(from: url)
return (url, image)
} catch {
return (url, nil)
}
}
}
for await (url, image) in group {
if let image = image {
results[url] = image
}
}
}
return results
}
/// Fetch team logos by team ID
func fetchTeamLogos(teams: [Team]) async -> [String: UIImage] {
let urlToTeam: [URL: String] = Dictionary(
teams.compactMap { team in
guard let logoURL = team.logoURL else { return nil }
return (logoURL, team.id)
},
uniquingKeysWith: { _, last in last }
)
let images = await fetchImages(from: Array(urlToTeam.keys))
var result: [String: UIImage] = [:]
for (url, image) in images {
if let teamId = urlToTeam[url] {
result[teamId] = image
}
}
return result
}
/// Fetch stadium photos by stadium ID
func fetchStadiumPhotos(stadiums: [Stadium]) async -> [String: UIImage] {
let urlToStadium: [URL: String] = Dictionary(
stadiums.compactMap { stadium in
guard let imageURL = stadium.imageURL else { return nil }
return (imageURL, stadium.id)
},
uniquingKeysWith: { _, last in last }
)
let images = await fetchImages(from: Array(urlToStadium.keys))
var result: [String: UIImage] = [:]
for (url, image) in images {
if let stadiumId = urlToStadium[url] {
result[stadiumId] = image
}
}
return result
}
/// Clear the in-memory cache
func clearCache() {
imageCache.removeAll()
}
// MARK: - Private Methods
/// Scale image to max width while maintaining aspect ratio
private func scaleImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
let currentWidth = image.size.width
guard currentWidth > maxWidth else { return image }
let scale = maxWidth / currentWidth
let newSize = CGSize(
width: image.size.width * scale,
height: image.size.height * scale
)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}