Files
Sportstime/SportsTime/Core/Services/VisitPhotoService.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

421 lines
13 KiB
Swift

//
// VisitPhotoService.swift
// SportsTime
//
// Manages visit photos with CloudKit sync for backup.
// Thumbnails stored locally in SwiftData for fast loading.
// Full images stored in CloudKit private database.
//
import Foundation
import CloudKit
import SwiftData
import UIKit
// MARK: - Photo Service Errors
enum PhotoServiceError: Error, LocalizedError {
case notSignedIn
case uploadFailed(String)
case downloadFailed(String)
case thumbnailGenerationFailed
case invalidImage
case assetNotFound
case quotaExceeded
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud to sync photos"
case .uploadFailed(let message):
return "Upload failed: \(message)"
case .downloadFailed(let message):
return "Download failed: \(message)"
case .thumbnailGenerationFailed:
return "Could not generate thumbnail"
case .invalidImage:
return "Invalid image data"
case .assetNotFound:
return "Photo not found in cloud storage"
case .quotaExceeded:
return "iCloud storage quota exceeded"
}
}
}
// MARK: - Visit Photo Service
@MainActor
final class VisitPhotoService {
// MARK: - Properties
private let modelContext: ModelContext
private let container: CKContainer
private let privateDatabase: CKDatabase
// Configuration
private static let thumbnailSize = CGSize(width: 200, height: 200)
private static let compressionQuality: CGFloat = 0.7
private static let recordType = "VisitPhoto"
// MARK: - Initialization
init(modelContext: ModelContext) {
self.modelContext = modelContext
self.container = CloudKitContainerConfig.makeContainer()
self.privateDatabase = container.privateCloudDatabase
}
// MARK: - Public API
/// Add a photo to a visit
/// - Parameters:
/// - visit: The visit to add the photo to
/// - image: The UIImage to add
/// - caption: Optional caption for the photo
/// - Returns: The created photo metadata
func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String? = nil) async throws -> VisitPhotoMetadata {
// Generate thumbnail
guard let thumbnail = generateThumbnail(from: image) else {
throw PhotoServiceError.thumbnailGenerationFailed
}
guard let thumbnailData = thumbnail.jpegData(compressionQuality: Self.compressionQuality) else {
throw PhotoServiceError.thumbnailGenerationFailed
}
// Get current photo count for order index
let orderIndex = visit.photoMetadata?.count ?? 0
// Create metadata record
let metadata = VisitPhotoMetadata(
visitId: visit.id,
cloudKitAssetId: nil,
thumbnailData: thumbnailData,
caption: caption,
orderIndex: orderIndex,
uploadStatus: .pending
)
// Add to visit
if visit.photoMetadata == nil {
visit.photoMetadata = []
}
visit.photoMetadata?.append(metadata)
modelContext.insert(metadata)
try modelContext.save()
// Queue background upload
Task { [weak self] in
await self?.uploadPhoto(metadata: metadata, image: image)
}
return metadata
}
/// Fetch full-resolution image for a photo
/// - Parameter metadata: The photo metadata
/// - Returns: The full-resolution UIImage
func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage {
guard let assetId = metadata.cloudKitAssetId else {
throw PhotoServiceError.assetNotFound
}
let recordID = CKRecord.ID(recordName: assetId)
do {
let record = try await privateDatabase.record(for: recordID)
guard let asset = record["imageAsset"] as? CKAsset,
let fileURL = asset.fileURL,
let data = try? Data(contentsOf: fileURL),
let image = UIImage(data: data) else {
throw PhotoServiceError.downloadFailed("Could not read image data")
}
return image
} catch let error as CKError {
throw mapCloudKitError(error)
}
}
/// Delete a photo from visit and CloudKit
/// - Parameter metadata: The photo metadata to delete
func deletePhoto(_ metadata: VisitPhotoMetadata) async throws {
// Delete from CloudKit if uploaded
if let assetId = metadata.cloudKitAssetId {
let recordID = CKRecord.ID(recordName: assetId)
do {
try await privateDatabase.deleteRecord(withID: recordID)
} catch {
// Continue with local deletion even if CloudKit fails
}
}
// Delete from SwiftData
modelContext.delete(metadata)
try modelContext.save()
}
/// Retry uploading failed photos
func retryFailedUploads() async {
let descriptor = FetchDescriptor<VisitPhotoMetadata>(
predicate: #Predicate { $0.uploadStatusRaw == "failed" || $0.uploadStatusRaw == "pending" }
)
do {
let pendingPhotos = try modelContext.fetch(descriptor)
for metadata in pendingPhotos {
// We can't upload without the original image
// Mark as failed permanently if no thumbnail
if metadata.thumbnailData == nil {
metadata.uploadStatus = .failed
}
}
try modelContext.save()
} catch {
// Silently fail - will retry on next launch
}
}
/// Get upload status summary
func getUploadStatus() -> (pending: Int, uploaded: Int, failed: Int) {
let descriptor = FetchDescriptor<VisitPhotoMetadata>()
do {
let all = try modelContext.fetch(descriptor)
let pending = all.filter { $0.uploadStatus == .pending }.count
let uploaded = all.filter { $0.uploadStatus == .uploaded }.count
let failed = all.filter { $0.uploadStatus == .failed }.count
return (pending, uploaded, failed)
} catch {
return (0, 0, 0)
}
}
/// Check if CloudKit is available for photo sync
func isCloudKitAvailable() async -> Bool {
do {
let status = try await container.accountStatus()
return status == .available
} catch {
return false
}
}
// MARK: - Private Methods
/// Prepare image data and temp file off the main actor
nonisolated private static func prepareImageData(
_ image: UIImage,
quality: CGFloat
) throws -> (Data, URL) {
guard let data = image.jpegData(compressionQuality: quality) else {
throw PhotoServiceError.invalidImage
}
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try data.write(to: tempURL)
return (data, tempURL)
}
private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async {
// Convert UIImage to Data on MainActor before crossing isolation boundaries
let quality = Self.compressionQuality
guard let imageData = image.jpegData(compressionQuality: quality) else {
metadata.uploadStatus = .failed
try? modelContext.save()
return
}
// Write image data to temp file off MainActor (imageData is Sendable)
let tempURL: URL
do {
tempURL = try await Task.detached(priority: .utility) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try imageData.write(to: url)
return url
}.value
} catch {
metadata.uploadStatus = .failed
try? modelContext.save()
return
}
// Check CloudKit availability
do {
let status = try await container.accountStatus()
guard status == .available else {
try? FileManager.default.removeItem(at: tempURL)
metadata.uploadStatus = .failed
try? modelContext.save()
return
}
} catch {
try? FileManager.default.removeItem(at: tempURL)
metadata.uploadStatus = .failed
try? modelContext.save()
return
}
// Create CloudKit record
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
do {
let asset = CKAsset(fileURL: tempURL)
record["imageAsset"] = asset
record["visitId"] = metadata.visitId.uuidString
record["caption"] = metadata.caption
record["orderIndex"] = metadata.orderIndex as CKRecordValue
// Upload to CloudKit
let savedRecord = try await privateDatabase.save(record)
// Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
// Update metadata
metadata.cloudKitAssetId = savedRecord.recordID.recordName
metadata.uploadStatus = .uploaded
try? modelContext.save()
} catch {
// Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
metadata.uploadStatus = .failed
try? modelContext.save()
}
}
private func generateThumbnail(from image: UIImage) -> UIImage? {
let size = Self.thumbnailSize
let aspectRatio = image.size.width / image.size.height
let targetSize: CGSize
if aspectRatio > 1 {
// Landscape
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
} else {
// Portrait or square
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
}
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
private func mapCloudKitError(_ error: CKError) -> PhotoServiceError {
switch error.code {
case .notAuthenticated:
return .notSignedIn
case .quotaExceeded:
return .quotaExceeded
case .unknownItem:
return .assetNotFound
default:
return .downloadFailed(error.localizedDescription)
}
}
}
// MARK: - Photo Gallery View Model
@Observable
@MainActor
final class PhotoGalleryViewModel {
var photos: [VisitPhotoMetadata] = []
var selectedPhoto: VisitPhotoMetadata?
var fullResolutionImage: UIImage?
var isLoadingFullImage = false
var error: PhotoServiceError?
private let photoService: VisitPhotoService
private let visit: StadiumVisit
init(visit: StadiumVisit, modelContext: ModelContext) {
self.visit = visit
self.photoService = VisitPhotoService(modelContext: modelContext)
loadPhotos()
}
func loadPhotos() {
photos = (visit.photoMetadata ?? []).sorted { $0.orderIndex < $1.orderIndex }
}
func addPhoto(_ image: UIImage, caption: String? = nil) async {
do {
let metadata = try await photoService.addPhoto(to: visit, image: image, caption: caption)
photos.append(metadata)
photos.sort { $0.orderIndex < $1.orderIndex }
} catch let error as PhotoServiceError {
self.error = error
} catch {
self.error = .uploadFailed(error.localizedDescription)
}
}
func selectPhoto(_ metadata: VisitPhotoMetadata) {
selectedPhoto = metadata
loadFullResolution(for: metadata)
}
func loadFullResolution(for metadata: VisitPhotoMetadata) {
guard metadata.cloudKitAssetId != nil else {
// Photo not uploaded yet, use thumbnail
if let data = metadata.thumbnailData {
fullResolutionImage = UIImage(data: data)
}
return
}
isLoadingFullImage = true
Task {
do {
let image = try await photoService.fetchFullImage(for: metadata)
fullResolutionImage = image
} catch let error as PhotoServiceError {
self.error = error
// Fall back to thumbnail
if let data = metadata.thumbnailData {
fullResolutionImage = UIImage(data: data)
}
} catch {
self.error = .downloadFailed(error.localizedDescription)
}
isLoadingFullImage = false
}
}
func deletePhoto(_ metadata: VisitPhotoMetadata) async {
do {
try await photoService.deletePhoto(metadata)
photos.removeAll { $0.id == metadata.id }
if selectedPhoto?.id == metadata.id {
selectedPhoto = nil
fullResolutionImage = nil
}
} catch let error as PhotoServiceError {
self.error = error
} catch {
self.error = .uploadFailed(error.localizedDescription)
}
}
func clearError() {
error = nil
}
}