From 8a3a8e185c460856990a6bb1db87376e62729642 Mon Sep 17 00:00:00 2001 From: John Lian Date: Tue, 21 Jul 2026 15:01:18 -0700 Subject: [PATCH 1/3] fix(ios): geotag in-app camera captures (#266) The in-app camera used UIImagePickerController and read the result as a bare UIImage, so camera photos reached the pipeline with no GPS (only the photo-library path got EXIF location). Location drives the range-prior re-ranker, so in-app captures could not benefit from geography. - Add NSLocationWhenInUseUsageDescription to Info.plist - New LocationService (CLLocationManager wrapper, when-in-use only) - Request authorization when the camera opens; stamp the device's current coordinate onto camera-captured ProcessedPhoto (graceful nil if denied) - Register LocationService.swift in the Xcode project Level 2 (custom AVFoundation camera + live detection overlay) tracked in #267. --- ios/WingDex.xcodeproj/project.pbxproj | 4 + ios/WingDex/Info.plist | 2 + ios/WingDex/Services/LocationService.swift | 85 +++++++++++++++++++ .../ViewModels/AddPhotosViewModel.swift | 23 +++-- .../AddPhotosFlow/PhotoSelectionView.swift | 6 +- 5 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 ios/WingDex/Services/LocationService.swift diff --git a/ios/WingDex.xcodeproj/project.pbxproj b/ios/WingDex.xcodeproj/project.pbxproj index 123e0356..2d324778 100644 --- a/ios/WingDex.xcodeproj/project.pbxproj +++ b/ios/WingDex.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 43921D83ECB05301E343D2ED /* collage11.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 99DD81C22AA9F61ABDD60B50 /* collage11.jpg */; }; 48572F12742996B68226EECF /* FunNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D47136847CF32C6251A4BD /* FunNames.swift */; }; 4BF3798F0ADD7B34948B1C65 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 494CD419AD323D541E04D2AB /* AuthService.swift */; }; + DCEF48F0819EB6083C514EA5 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C391CE53E6D744A318E8B40 /* LocationService.swift */; }; 4D2754D2E24874B1AB391719 /* collage19.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1758F97178B6D85D64F1363D /* collage19.jpg */; }; 4F4AEE471B77C7087B5DB09F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34071627BDA774005F033 /* SettingsView.swift */; }; 58B69A80EFAF3294811D2CE3 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E4A1B21654CC24CA891696 /* AuthIntegrationTests.swift */; }; @@ -123,6 +124,7 @@ 452C55049EB9AB7E09E62CA9 /* FunNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunNamesTests.swift; sourceTree = ""; }; 487950E4852A38D4FFA3B405 /* collage24.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage24.jpg; sourceTree = ""; }; 494CD419AD323D541E04D2AB /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; + 3C391CE53E6D744A318E8B40 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; 4BDBF72550893F931771EA36 /* CelebrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CelebrationTests.swift; sourceTree = ""; }; 4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxonomyOrderTests.swift; sourceTree = ""; }; 557A070AA631D1A44FE4F407 /* GitInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitInfo.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ 5CACACCB759F9881B6989222 /* CropService.swift */, CFBE1598DFE8F27ED8313060 /* DataService.swift */, 8DF3BE4EAF4D781A2029593E /* DataStore.swift */, + 3C391CE53E6D744A318E8B40 /* LocationService.swift */, 19D47136847CF32C6251A4BD /* FunNames.swift */, C55734005BE9F01B6719A826 /* PasskeyService.swift */, 5873328D24830AA514049E5C /* PhotoService.swift */, @@ -535,6 +538,7 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, CCB60C37FEB5B3C7781CF308 /* AppIconView.swift in Sources */, 0C26BD71C3D1128D41123339 /* AppModels.swift in Sources */, 4BF3798F0ADD7B34948B1C65 /* AuthService.swift in Sources */, + DCEF48F0819EB6083C514EA5 /* LocationService.swift in Sources */, 78A345C5C3608103CBE304D2 /* AuthenticatedRequest.swift in Sources */, 701F62DDB95D5BDF65E3E23D /* CelebrationOverlay.swift in Sources */, 28C700594AF36399A460FAAB /* CollageImageCache.swift in Sources */, diff --git a/ios/WingDex/Info.plist b/ios/WingDex/Info.plist index f4a00d22..74a9bcd9 100644 --- a/ios/WingDex/Info.plist +++ b/ios/WingDex/Info.plist @@ -33,6 +33,8 @@ NSCameraUsageDescription WingDex uses the camera so you can take bird photos for identification. + NSLocationWhenInUseUsageDescription + WingDex uses your location to tag where a bird photo was taken, which improves identification accuracy. NSPhotoLibraryAddUsageDescription WingDex may save captured bird photos to your library when you choose to keep them. NSPhotoLibraryUsageDescription diff --git a/ios/WingDex/Services/LocationService.swift b/ios/WingDex/Services/LocationService.swift new file mode 100644 index 00000000..c3014310 --- /dev/null +++ b/ios/WingDex/Services/LocationService.swift @@ -0,0 +1,85 @@ +import CoreLocation +import Foundation + +/// Provides the device's current location for geotagging in-app camera captures. +/// +/// The photo-library path already reads GPS from each photo's EXIF metadata +/// (`PhotoService.extractEXIF`), but photos captured with the in-app camera are +/// bare pixels with no embedded location. This service supplies the device's +/// current coordinate at capture time so camera photos get the same GPS signal +/// the range-prior pipeline (and future on-device model) relies on. +/// +/// Usage: hold a single instance for the capture flow, call `requestAuthorization()` +/// when the camera opens, and read `currentLocation` (or `latestCoordinate`) when +/// a photo is taken. When-in-use authorization only; no background tracking. +@MainActor +final class LocationService: NSObject, ObservableObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + /// Most recent location fix, if any. + @Published private(set) var currentLocation: CLLocation? + + /// Current authorization status. + @Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + authorizationStatus = manager.authorizationStatus + } + + /// Convenience: the latest coordinate as (lat, lon), or nil if unavailable. + var latestCoordinate: (lat: Double, lon: Double)? { + guard let coord = currentLocation?.coordinate, + CLLocationCoordinate2DIsValid(coord) + else { return nil } + return (coord.latitude, coord.longitude) + } + + /// Whether we currently have permission to read location. + var isAuthorized: Bool { + authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + } + + /// Request when-in-use permission and begin updating if granted. Safe to call + /// repeatedly (e.g. each time the camera opens). + func requestAuthorization() { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + manager.startUpdatingLocation() + default: + break // denied/restricted: photos simply won't be geotagged + } + } + + /// Stop location updates (call when the capture flow closes to save battery). + func stop() { + manager.stopUpdatingLocation() + } + + // MARK: - CLLocationManagerDelegate + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.authorizationStatus = status + if status == .authorizedWhenInUse || status == .authorizedAlways { + manager.startUpdatingLocation() + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + Task { @MainActor in + self.currentLocation = loc + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // Non-fatal: capture still works, photo just won't be geotagged. + } +} diff --git a/ios/WingDex/ViewModels/AddPhotosViewModel.swift b/ios/WingDex/ViewModels/AddPhotosViewModel.swift index e7b996a6..22a18a12 100644 --- a/ios/WingDex/ViewModels/AddPhotosViewModel.swift +++ b/ios/WingDex/ViewModels/AddPhotosViewModel.swift @@ -56,8 +56,10 @@ final class AddPhotosViewModel { var selectedItems: [PhotosPickerItem] = [] var processedPhotos: [ProcessedPhoto] = [] - /// Photos captured via the camera (UIImage, not from PhotosPicker). - var cameraPhotos: [UIImage] = [] + /// Photos captured via the camera (UIImage + capture-time location, not from + /// PhotosPicker). The in-app camera returns bare pixels with no EXIF GPS, so + /// we carry the device location captured alongside each shot. + var cameraPhotos: [(image: UIImage, lat: Double?, lon: Double?)] = [] // MARK: - Clustering @@ -172,9 +174,10 @@ final class AddPhotosViewModel { // MARK: - Camera Support - /// Add a photo captured from the camera. - func addCameraPhoto(_ image: UIImage) { - cameraPhotos.append(image) + /// Add a photo captured from the camera, with the device location at capture + /// time (nil if location was unavailable or permission was denied). + func addCameraPhoto(_ image: UIImage, lat: Double?, lon: Double?) { + cameraPhotos.append((image: image, lat: lat, lon: lon)) } // MARK: - Step 1: Process Selected Photos @@ -236,8 +239,10 @@ final class AddPhotosViewModel { extractionProgress = Double(processedCount) / Double(totalCount) * 100 } - // Process camera-captured photos (no EXIF GPS, use capture time as now) - for uiImage in cameraPhotos { + // Process camera-captured photos (no EXIF GPS; use the device location + // captured at shot time, and capture time as now). + for camera in cameraPhotos { + let uiImage = camera.image let id = UUID().uuidString let compressed = PhotoService.compressImage(uiImage, quality: 0.7) ?? Data() let thumbnail = PhotoService.generateThumbnail(from: compressed, maxDimension: 200) ?? compressed @@ -248,8 +253,8 @@ final class AddPhotosViewModel { image: compressed, thumbnail: thumbnail, exifTime: Date(), - gpsLat: nil, - gpsLon: nil, + gpsLat: camera.lat, + gpsLon: camera.lon, fileHash: fileHash, fileName: "camera_\(id).jpg" ) diff --git a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift index f9cc3f62..4909e24e 100644 --- a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift +++ b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift @@ -36,6 +36,7 @@ struct PhotoSelectionView: View { @Bindable var viewModel: AddPhotosViewModel @State private var showCamera = false @State private var collageDrag: CGSize = .zero + @StateObject private var locationService = LocationService() private static let collageImages = CollageImageCache.names @@ -119,6 +120,7 @@ struct PhotoSelectionView: View { .buttonSizing(.flexible) Button { + locationService.requestAuthorization() showCamera = true } label: { Label("Take Photo", systemImage: "camera") @@ -167,12 +169,14 @@ struct PhotoSelectionView: View { } } .fullScreenCover(isPresented: $showCamera, onDismiss: { + locationService.stop() if !viewModel.cameraPhotos.isEmpty { Task { await viewModel.processSelectedPhotos() } } }) { CameraCaptureView { image in - viewModel.addCameraPhoto(image) + let coord = locationService.latestCoordinate + viewModel.addCameraPhoto(image, lat: coord?.lat, lon: coord?.lon) } .ignoresSafeArea() } From a81aecc65f5d8599e25af60d60862744e92b39fc Mon Sep 17 00:00:00 2001 From: John Lian Date: Tue, 21 Jul 2026 15:13:26 -0700 Subject: [PATCH 2/3] fix(ios): use CLLocationUpdate async API for Swift 6 strict concurrency The initial LocationService used the CLLocationManagerDelegate pattern, which fights Swift 6 complete strict concurrency (the project targets iOS 26 / Swift 6): non-Sendable CLLocationManager captured across the main-actor hop, delegate isolation assertions. Rewrite on the modern CLLocationUpdate.liveUpdates() AsyncSequence (iOS 17+): a @MainActor class that streams updates in a Task, no delegate. liveUpdates() auto-prompts when authorization is .notDetermined; a denied stream yields nil locations, so captures degrade gracefully to ungeotagged. requestWhenInUseAuthorization() on start() prompts on button tap. - start()/stop() replace requestAuthorization(); view updated accordingly. --- ios/WingDex/Services/LocationService.swift | 82 +++++++------------ .../AddPhotosFlow/PhotoSelectionView.swift | 2 +- 2 files changed, 30 insertions(+), 54 deletions(-) diff --git a/ios/WingDex/Services/LocationService.swift b/ios/WingDex/Services/LocationService.swift index c3014310..8b144d9a 100644 --- a/ios/WingDex/Services/LocationService.swift +++ b/ios/WingDex/Services/LocationService.swift @@ -9,25 +9,20 @@ import Foundation /// current coordinate at capture time so camera photos get the same GPS signal /// the range-prior pipeline (and future on-device model) relies on. /// -/// Usage: hold a single instance for the capture flow, call `requestAuthorization()` -/// when the camera opens, and read `currentLocation` (or `latestCoordinate`) when -/// a photo is taken. When-in-use authorization only; no background tracking. +/// Uses the modern `CLLocationUpdate.liveUpdates()` async sequence (iOS 17+) +/// rather than the delegate pattern, which keeps it clean under Swift 6 strict +/// concurrency. When-in-use authorization only; no background tracking. +/// +/// Usage: call `start()` when the camera opens, read `latestCoordinate` when a +/// photo is taken, and `stop()` when the capture flow closes. @MainActor -final class LocationService: NSObject, ObservableObject, CLLocationManagerDelegate { - private let manager = CLLocationManager() - +final class LocationService: ObservableObject { /// Most recent location fix, if any. @Published private(set) var currentLocation: CLLocation? - /// Current authorization status. - @Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined - - override init() { - super.init() - manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyHundredMeters - authorizationStatus = manager.authorizationStatus - } + /// Manager retained only to request authorization (no delegate is used). + private let manager = CLLocationManager() + private var updatesTask: Task? /// Convenience: the latest coordinate as (lat, lon), or nil if unavailable. var latestCoordinate: (lat: Double, lon: Double)? { @@ -37,49 +32,30 @@ final class LocationService: NSObject, ObservableObject, CLLocationManagerDelega return (coord.latitude, coord.longitude) } - /// Whether we currently have permission to read location. - var isAuthorized: Bool { - authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways - } - - /// Request when-in-use permission and begin updating if granted. Safe to call - /// repeatedly (e.g. each time the camera opens). - func requestAuthorization() { - switch manager.authorizationStatus { - case .notDetermined: + /// Request when-in-use permission (if needed) and begin streaming location. + /// Safe to call repeatedly; a second call is a no-op while already running. + func start() { + if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() - case .authorizedWhenInUse, .authorizedAlways: - manager.startUpdatingLocation() - default: - break // denied/restricted: photos simply won't be geotagged } - } - - /// Stop location updates (call when the capture flow closes to save battery). - func stop() { - manager.stopUpdatingLocation() - } - - // MARK: - CLLocationManagerDelegate - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.authorizationStatus = status - if status == .authorizedWhenInUse || status == .authorizedAlways { - manager.startUpdatingLocation() + guard updatesTask == nil else { return } + updatesTask = Task { [weak self] in + do { + for try await update in CLLocationUpdate.liveUpdates() { + guard let self else { return } + if let location = update.location { + self.currentLocation = location + } + } + } catch { + // Non-fatal: capture still works, photo just won't be geotagged. } } } - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let loc = locations.last else { return } - Task { @MainActor in - self.currentLocation = loc - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - // Non-fatal: capture still works, photo just won't be geotagged. + /// Stop location updates (call when the capture flow closes to save battery). + func stop() { + updatesTask?.cancel() + updatesTask = nil } } diff --git a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift index 4909e24e..f07be743 100644 --- a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift +++ b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift @@ -120,7 +120,7 @@ struct PhotoSelectionView: View { .buttonSizing(.flexible) Button { - locationService.requestAuthorization() + locationService.start() showCamera = true } label: { Label("Take Photo", systemImage: "camera") From 447c2bd468c78b1e8651b8373e7616791e01467a Mon Sep 17 00:00:00 2001 From: John Lian Date: Tue, 21 Jul 2026 15:28:27 -0700 Subject: [PATCH 3/3] fix(ios): address Copilot review on camera-location PR - LocationService: clear updatesTask when the liveUpdates() stream ends or throws, so a later start() can restart it (previously a terminated stream left the service permanently 'running'). Guarded on !isCancelled so stop()/restart isn't clobbered. - Correct misleading comment: camera photos use processing time as the timestamp, not shutter time. --- ios/WingDex/Services/LocationService.swift | 6 ++++++ ios/WingDex/ViewModels/AddPhotosViewModel.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/WingDex/Services/LocationService.swift b/ios/WingDex/Services/LocationService.swift index 8b144d9a..d6f7514f 100644 --- a/ios/WingDex/Services/LocationService.swift +++ b/ios/WingDex/Services/LocationService.swift @@ -50,6 +50,12 @@ final class LocationService: ObservableObject { } catch { // Non-fatal: capture still works, photo just won't be geotagged. } + // Clear our own handle when the stream ends/throws, but only if a + // newer start() hasn't already replaced it (avoids clobbering a + // restart). Cancellation is handled by stop(). + if let self, !Task.isCancelled { + self.updatesTask = nil + } } } diff --git a/ios/WingDex/ViewModels/AddPhotosViewModel.swift b/ios/WingDex/ViewModels/AddPhotosViewModel.swift index 22a18a12..8194047c 100644 --- a/ios/WingDex/ViewModels/AddPhotosViewModel.swift +++ b/ios/WingDex/ViewModels/AddPhotosViewModel.swift @@ -240,7 +240,7 @@ final class AddPhotosViewModel { } // Process camera-captured photos (no EXIF GPS; use the device location - // captured at shot time, and capture time as now). + // captured at shot time, and the processing time as the timestamp). for camera in cameraPhotos { let uiImage = camera.image let id = UUID().uuidString