diff --git a/nativephp.json b/nativephp.json index f66bba2..5ba1697 100644 --- a/nativephp.json +++ b/nativephp.json @@ -26,10 +26,13 @@ "android.permission.CAMERA", "android.permission.RECORD_AUDIO", "android.permission.FOREGROUND_SERVICE", - "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" + "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE", + "android.permission.ACCESS_MEDIA_LOCATION" ], "dependencies": { - "implementation": [] + "implementation": [ + "androidx.exifinterface:exifinterface:1.3.7" + ] }, "services": [ { @@ -44,7 +47,8 @@ "info_plist": { "NSCameraUsageDescription": "This app requires camera access to take photos and videos.", "NSMicrophoneUsageDescription": "This app requires microphone access to record video with audio.", - "NSPhotoLibraryUsageDescription": "This app requires photo library access to select media." + "NSPhotoLibraryUsageDescription": "This app requires photo library access to select media.", + "NSLocationWhenInUseUsageDescription": "This app uses your location to tag photos with the place they were taken." }, "dependencies": { "swift_packages": [], diff --git a/resources/android/CameraCoordinator.kt b/resources/android/CameraCoordinator.kt index 004352a..0b73ff2 100644 --- a/resources/android/CameraCoordinator.kt +++ b/resources/android/CameraCoordinator.kt @@ -2,9 +2,11 @@ package com.nativephp.camera import android.Manifest import android.content.ContentValues +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.util.Log @@ -13,12 +15,17 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.exifinterface.media.ExifInterface import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.nativephp.mobile.utils.NativeActionCoordinator import org.json.JSONArray import org.json.JSONObject import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -66,6 +73,11 @@ class CameraCoordinator : Fragment() { // Gallery state private var pendingGalleryId: String? = null private var pendingGalleryEvent: String? = null + // Retained gallery launch arguments so the picker can be re-launched after + // resolving the ACCESS_MEDIA_LOCATION runtime permission. + private var pendingGalleryMediaType: String? = null + private var pendingGalleryMultiple: Boolean = false + private var pendingGalleryMaxItems: Int = 10 // Background processing private var fileProcessingExecutor: ExecutorService? = null @@ -74,6 +86,7 @@ class CameraCoordinator : Fragment() { private lateinit var cameraLauncher: ActivityResultLauncher private lateinit var videoRecorderLauncher: ActivityResultLauncher private lateinit var cameraPermissionLauncher: ActivityResultLauncher + private lateinit var mediaLocationPermissionLauncher: ActivityResultLauncher private lateinit var galleryPickerSingle: ActivityResultLauncher private lateinit var galleryPickerMultiple: ActivityResultLauncher @@ -141,6 +154,16 @@ class CameraCoordinator : Fragment() { } } + // Media location permission launcher (ACCESS_MEDIA_LOCATION). + // Whatever the user decides, we proceed to launch the gallery picker; the + // permission only controls whether un-redacted GPS metadata can be recovered. + mediaLocationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + Log.d(TAG, "🖼️ Media location permission result: $granted") + launchGalleryPicker() + } + // Camera launcher for photos cameraLauncher = registerForActivityResult( ActivityResultContracts.TakePicture() @@ -161,38 +184,55 @@ class CameraCoordinator : Fragment() { val cancelEventClass = "Native\\Mobile\\Events\\Camera\\PhotoCancelled" if (success && pendingCameraUri != null) { - val dst = File(context.cacheDir, "captured_${System.currentTimeMillis()}.jpg") + // Snapshot pending state before handing off to the background thread so the + // launcher can clean up immediately without racing the worker. + val sourceUri = pendingCameraUri!! + val photoId = pendingPhotoId + + // Copy + EXIF reading is file IO, so run it off the main thread. + fileProcessingExecutor?.execute { + val dst = File(context.cacheDir, "captured_${System.currentTimeMillis()}.jpg") - try { - context.contentResolver.openInputStream(pendingCameraUri!!)?.use { input -> - dst.outputStream().buffered(64 * 1024).use { output -> - input.copyTo(output) - } - } - // Clean up MediaStore entry try { - context.contentResolver.delete(pendingCameraUri!!, null, null) - } catch (e: Exception) { - Log.w(TAG, "⚠️ Could not delete MediaStore entry: ${e.message}") - } + context.contentResolver.openInputStream(sourceUri)?.use { input -> + dst.outputStream().buffered(64 * 1024).use { output -> + input.copyTo(output) + } + } + // Clean up MediaStore entry + try { + context.contentResolver.delete(sourceUri, null, null) + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not delete MediaStore entry: ${e.message}") + } - val payload = JSONObject().apply { - put("path", dst.absolutePath) - put("mimeType", "image/jpeg") - pendingPhotoId?.let { put("id", it) } - } + val payload = JSONObject().apply { + put("path", dst.absolutePath) + put("mimeType", "image/jpeg") + photoId?.let { put("id", it) } + } - dispatchEvent(eventClass, payload.toString()) - Log.d(TAG, "✅ Photo captured successfully: ${dst.absolutePath}") - } catch (e: Exception) { - Log.e(TAG, "❌ Error processing camera photo: ${e.message}", e) - Toast.makeText(context, "Failed to save photo", Toast.LENGTH_SHORT).show() + // Attach capture date + GPS from the embedded EXIF. The date falls + // back to the current time so takenAt is essentially always present. + attachImageMetadata(payload, dst.absolutePath, fallbackToNow = true) - val payload = JSONObject().apply { - put("cancelled", true) - pendingPhotoId?.let { put("id", it) } + activity?.runOnUiThread { + dispatchEvent(eventClass, payload.toString()) + Log.d(TAG, "✅ Photo captured successfully: ${dst.absolutePath}") + } + } catch (e: Exception) { + Log.e(TAG, "❌ Error processing camera photo: ${e.message}", e) + + activity?.runOnUiThread { + Toast.makeText(context, "Failed to save photo", Toast.LENGTH_SHORT).show() + + val payload = JSONObject().apply { + put("cancelled", true) + photoId?.let { put("id", it) } + } + dispatchEvent(cancelEventClass, payload.toString()) + } } - dispatchEvent(cancelEventClass, payload.toString()) } } else { Log.d(TAG, "⚠️ Camera capture was canceled or failed") @@ -616,8 +656,33 @@ class CameraCoordinator : Fragment() { pendingGalleryId = id pendingGalleryEvent = event + pendingGalleryMediaType = mediaType + pendingGalleryMultiple = multiple + pendingGalleryMaxItems = maxItems + + // The Photo Picker redacts GPS metadata unless we read the original bytes via + // MediaStore, which requires ACCESS_MEDIA_LOCATION (a runtime permission on API 29+). + // Request it up-front, mirroring the CAMERA permission flow. Whatever the result, + // we still launch the picker; location recovery just degrades gracefully. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val context = requireContext() + val mediaLocationGranted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + if (!mediaLocationGranted) { + Log.d(TAG, "🖼️ ACCESS_MEDIA_LOCATION not granted, requesting permission") + mediaLocationPermissionLauncher.launch(Manifest.permission.ACCESS_MEDIA_LOCATION) + return + } + } + + launchGalleryPicker() + } - val visualMediaType = when (mediaType.lowercase()) { + private fun launchGalleryPicker() { + val visualMediaType = when ((pendingGalleryMediaType ?: "all").lowercase()) { "image", "images" -> ActivityResultContracts.PickVisualMedia.ImageOnly "video", "videos" -> ActivityResultContracts.PickVisualMedia.VideoOnly "all", "*" -> ActivityResultContracts.PickVisualMedia.ImageAndVideo @@ -626,7 +691,7 @@ class CameraCoordinator : Fragment() { Log.d(TAG, "📂 Using visual media type: $visualMediaType") - if (multiple) { + if (pendingGalleryMultiple) { Log.d(TAG, "🚀 Launching multiple gallery picker") val request = PickVisualMediaRequest.Builder() .setMediaType(visualMediaType) @@ -721,6 +786,12 @@ class CameraCoordinator : Fragment() { put("type", type) } + // For images, attach capture date + GPS. The Photo Picker redacts GPS, so we + // also attempt to recover the original (un-redacted) bytes via MediaStore. + if (type == "image") { + attachImageMetadata(metadata, cachePath, fallbackToNow = false, sourceUri = uri) + } + } catch (e: Exception) { Log.e(TAG, "❌ Error getting file metadata", e) // Fallback metadata @@ -735,6 +806,196 @@ class CameraCoordinator : Fragment() { return metadata } + /** + * Read capture date + GPS metadata from an image and merge it into [payload] using the + * shared cross-platform payload contract: + * - takenAt: ISO-8601 UTC string (yyyy-MM-dd'T'HH:mm:ss'Z'), omitted when unavailable. + * - latitude / longitude: Double decimal degrees, omitted when unavailable. + * + * The capture date is read from the copied file's EXIF (TAG_DATETIME_ORIGINAL, falling back + * to TAG_DATETIME), then MediaStore DATE_TAKEN, then optionally the current time. + * + * GPS is read from the copied file's EXIF first. The Photo Picker redacts location, so when a + * [sourceUri] is supplied we additionally try to recover the original (un-redacted) bytes via + * MediaStore.setRequireOriginal (requires ACCESS_MEDIA_LOCATION on API 29+). + * + * All access is best-effort: any failure simply omits the affected key and never throws. + */ + private fun attachImageMetadata( + payload: JSONObject, + cachePath: String, + fallbackToNow: Boolean, + sourceUri: Uri? = null + ) { + val context = context ?: return + + var takenAtMillis: Long? = null + var latitude: Double? = null + var longitude: Double? = null + + // 1. Read EXIF from the copied cache file. + try { + val exif = ExifInterface(cachePath) + + takenAtMillis = parseExifDate( + exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + ?: exif.getAttribute(ExifInterface.TAG_DATETIME) + ) + + exif.latLong?.let { latLong -> + latitude = latLong[0] + longitude = latLong[1] + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not read EXIF from cache file: ${e.message}") + } + + // 2. The Photo Picker redacts GPS, so try to recover it from the original bytes. + if ((latitude == null || longitude == null) && sourceUri != null) { + recoverOriginalLatLong(context, sourceUri)?.let { latLong -> + latitude = latLong[0] + longitude = latLong[1] + } + + // While we have the original stream open, also try to recover the EXIF date if missing. + if (takenAtMillis == null) { + takenAtMillis = recoverOriginalExifDate(context, sourceUri) + } + } + + // 3. Fall back to MediaStore DATE_TAKEN for the capture date. + if (takenAtMillis == null && sourceUri != null) { + takenAtMillis = queryMediaStoreDateTaken(context, sourceUri) + } + + // 4. Final fallback to the current time (camera capture only). + if (takenAtMillis == null && fallbackToNow) { + takenAtMillis = System.currentTimeMillis() + } + + takenAtMillis?.let { payload.put("takenAt", formatUtcIso8601(it)) } + latitude?.let { lat -> longitude?.let { lon -> + payload.put("latitude", lat) + payload.put("longitude", lon) + } } + } + + /** + * Attempt to recover un-redacted GPS from the original MediaStore image, returning + * [latitude, longitude] in decimal degrees or null if unavailable. + */ + private fun recoverOriginalLatLong(context: Context, uri: Uri): DoubleArray? { + return try { + openOriginalStreamExif(context, uri)?.latLong + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not recover original GPS: ${e.message}") + null + } + } + + /** + * Attempt to recover the EXIF capture date from the original MediaStore image, in millis. + */ + private fun recoverOriginalExifDate(context: Context, uri: Uri): Long? { + return try { + val exif = openOriginalStreamExif(context, uri) ?: return null + parseExifDate( + exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + ?: exif.getAttribute(ExifInterface.TAG_DATETIME) + ) + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not recover original EXIF date: ${e.message}") + null + } + } + + /** + * Open an [ExifInterface] over the original (un-redacted) bytes of a MediaStore-backed uri. + * Uses MediaStore.setRequireOriginal on API 29+, which needs ACCESS_MEDIA_LOCATION. Returns + * null when the permission is missing or the uri is not MediaStore-backed. + */ + private fun openOriginalStreamExif(context: Context, uri: Uri): ExifInterface? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return null + } + + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + return null + } + + val originalUri = try { + MediaStore.setRequireOriginal(uri) + } catch (e: Exception) { + // Not a MediaStore uri (e.g. provider-backed) or original unavailable. + return null + } + + return context.contentResolver.openInputStream(originalUri)?.use { input -> + ExifInterface(input) + } + } + + /** + * Query MediaStore DATE_TAKEN (epoch millis) for the given uri, or null if unavailable. + */ + private fun queryMediaStoreDateTaken(context: Context, uri: Uri): Long? { + return try { + context.contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.DATE_TAKEN), + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN) + if (index >= 0 && !cursor.isNull(index)) { + val value = cursor.getLong(index) + if (value > 0) value else null + } else { + null + } + } else { + null + } + } + } catch (e: Exception) { + Log.w(TAG, "⚠️ Could not query MediaStore DATE_TAKEN: ${e.message}") + null + } + } + + /** + * Parse an EXIF date string (yyyy:MM:dd HH:mm:ss, device-local) into epoch millis, or null. + */ + private fun parseExifDate(raw: String?): Long? { + if (raw.isNullOrBlank()) { + return null + } + return try { + val parser = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getDefault() + } + parser.parse(raw)?.time + } catch (e: Exception) { + null + } + } + + /** + * Format epoch millis as an ISO-8601 UTC string: yyyy-MM-dd'T'HH:mm:ss'Z'. + */ + private fun formatUtcIso8601(millis: Long): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return formatter.format(Date(millis)) + } + private fun dispatchEvent(event: String, payloadJson: String) { NativeActionCoordinator.dispatchEvent(requireActivity(), event, payloadJson) } diff --git a/resources/ios/CameraFunctions.swift b/resources/ios/CameraFunctions.swift index 84a2666..a421976 100644 --- a/resources/ios/CameraFunctions.swift +++ b/resources/ios/CameraFunctions.swift @@ -3,6 +3,9 @@ import UIKit import AVFoundation import UniformTypeIdentifiers import PhotosUI +import Photos +import CoreLocation +import ImageIO // MARK: - Camera Function Namespace @@ -80,6 +83,10 @@ enum CameraFunctions { CameraPhotoDelegate.shared.pendingPhotoId = id CameraPhotoDelegate.shared.pendingPhotoEvent = event + // Begin gathering a location fix so the capture can be geotagged. + // Best-effort: never blocks or fails the capture. + CameraLocationProvider.shared.start() + guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), @@ -261,6 +268,147 @@ enum CameraFunctions { } } +// MARK: - Metadata Helpers + +/// Shared helpers for formatting capture metadata into the cross-platform payload contract. +/// Keys produced: `takenAt` (ISO-8601 UTC string), `latitude` (Double), `longitude` (Double). +/// All keys are OMITTED when their value is unavailable (never NSNull). +enum CameraMetadata { + + /// ISO-8601 UTC formatter producing `yyyy-MM-dd'T'HH:mm:ss'Z'`. + static let isoFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + return formatter + }() + + /// Parser for Exif `DateTimeOriginal` strings, which use the form `yyyy:MM:dd HH:mm:ss` + /// in the device's local time zone. + static let exifDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + return formatter + }() + + /// Format a `Date` as an ISO-8601 UTC string for the `takenAt` payload key. + static func isoString(from date: Date) -> String { + return isoFormatter.string(from: date) + } + + /// Parse an Exif `DateTimeOriginal` value out of an image metadata dictionary. + /// Returns nil if the dictionary has no usable Exif date. + static func dateTimeOriginal(from metadata: [String: Any]) -> Date? { + guard let exif = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any] else { + return nil + } + guard let raw = exif[kCGImagePropertyExifDateTimeOriginal as String] as? String else { + return nil + } + return exifDateFormatter.date(from: raw) + } + + /// Extract a coordinate from a GPS sub-dictionary in an image metadata dictionary. + /// Honours the latitude/longitude reference fields (N/S, E/W). + static func coordinate(from metadata: [String: Any]) -> CLLocationCoordinate2D? { + guard let gps = metadata[kCGImagePropertyGPSDictionary as String] as? [String: Any], + var latitude = gps[kCGImagePropertyGPSLatitude as String] as? Double, + var longitude = gps[kCGImagePropertyGPSLongitude as String] as? Double else { + return nil + } + + if let latRef = gps[kCGImagePropertyGPSLatitudeRef as String] as? String, + latRef.uppercased() == "S" { + latitude = -latitude + } + if let lonRef = gps[kCGImagePropertyGPSLongitudeRef as String] as? String, + lonRef.uppercased() == "W" { + longitude = -longitude + } + + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + /// Build a CoreGraphics GPS dictionary from a CLLocation, suitable for embedding via ImageIO. + static func gpsDictionary(from location: CLLocation) -> [String: Any] { + let coordinate = location.coordinate + var gps: [String: Any] = [:] + + gps[kCGImagePropertyGPSLatitude as String] = abs(coordinate.latitude) + gps[kCGImagePropertyGPSLatitudeRef as String] = coordinate.latitude >= 0 ? "N" : "S" + gps[kCGImagePropertyGPSLongitude as String] = abs(coordinate.longitude) + gps[kCGImagePropertyGPSLongitudeRef as String] = coordinate.longitude >= 0 ? "E" : "W" + + if location.verticalAccuracy >= 0 { + gps[kCGImagePropertyGPSAltitude as String] = abs(location.altitude) + gps[kCGImagePropertyGPSAltitudeRef as String] = location.altitude >= 0 ? 0 : 1 + } + + return gps + } +} + +// MARK: - Location Provider + +/// Lightweight wrapper around CLLocationManager used to tag camera captures with a coordinate. +/// +/// `UIImagePickerController` only embeds GPS into the media metadata when the app holds +/// location authorization, so we keep a recent fix on hand and synthesise GPS when needed. +/// All access is best-effort: it never blocks capture and never crashes when location is +/// unavailable or denied. +final class CameraLocationProvider: NSObject, CLLocationManagerDelegate { + + static let shared = CameraLocationProvider() + + private let manager = CLLocationManager() + + private override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + /// Request When-In-Use authorization (no-op if already determined) and begin + /// receiving updates so a recent fix is available by capture time. + func start() { + let status = manager.authorizationStatus + if status == .notDetermined { + manager.requestWhenInUseAuthorization() + } + if status == .authorizedWhenInUse || status == .authorizedAlways || status == .notDetermined { + manager.startUpdatingLocation() + } + } + + /// Stop updates to conserve power once a capture flow has finished. + func stop() { + manager.stopUpdatingLocation() + } + + /// The most recent location fix, if one is available and authorization is granted. + var currentLocation: CLLocation? { + let status = manager.authorizationStatus + guard status == .authorizedWhenInUse || status == .authorizedAlways else { + return nil + } + return manager.location + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + if status == .authorizedWhenInUse || status == .authorizedAlways { + manager.startUpdatingLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // Best-effort only; ignore failures so capture is never blocked. + } +} + // MARK: - Video Delegate final class CameraVideoDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @@ -409,9 +557,19 @@ final class CameraPhotoDelegate: NSObject, UIImagePickerControllerDelegate, UINa // Clean up pendingPhotoId = nil pendingPhotoEvent = nil + CameraLocationProvider.shared.stop() return } + // Capture the original media metadata (Exif / TIFF / GPS sub-dictionaries) while it is + // still available. A UIImage carries no EXIF, so we must read it from the picker info + // and write it back into the output JPEG ourselves. + let mediaMetadata = (info[.mediaMetadata] as? [String: Any]) ?? [:] + + // Grab the best location fix we have at capture time (may be nil). + let captureLocation = CameraLocationProvider.shared.currentLocation + CameraLocationProvider.shared.stop() + // Save on a background queue DispatchQueue.global(qos: .utility).async { [weak self] in let fm = FileManager.default @@ -422,7 +580,7 @@ final class CameraPhotoDelegate: NSObject, UIImagePickerControllerDelegate, UINa // Generate unique filename let timestamp = Int(Date().timeIntervalSince1970 * 1000) let filename = "captured_photo_\(timestamp).jpg" - var fileURL = tempDir.appendingPathComponent(filename) + let fileURL = tempDir.appendingPathComponent(filename) do { // Remove existing file if present @@ -430,35 +588,65 @@ final class CameraPhotoDelegate: NSObject, UIImagePickerControllerDelegate, UINa try fm.removeItem(at: fileURL) } - // Convert to JPEG and save - guard let jpegData = image.jpegData(compressionQuality: 0.9) else { - print("❌ Failed to convert image to JPEG") + // Build the metadata dictionary to embed, starting from the original capture + // metadata so Exif (incl. DateTimeOriginal) and any existing GPS are preserved. + var imageProperties = mediaMetadata + + // If the capture metadata lacks GPS (e.g. no location authorization at capture + // time), synthesise one from our CLLocation fix so the file stays geotagged. + let hasGPS = imageProperties[kCGImagePropertyGPSDictionary as String] != nil + if !hasGPS, let location = captureLocation { + imageProperties[kCGImagePropertyGPSDictionary as String] = + CameraMetadata.gpsDictionary(from: location) + } + + // Preserve JPEG compression behaviour (0.9) while writing metadata. + imageProperties[kCGImageDestinationLossyCompressionQuality as String] = 0.9 + + // Write the image + metadata using ImageIO so EXIF/GPS survive. + guard let cgImage = image.cgImage, + let destination = CGImageDestinationCreateWithURL( + fileURL as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + print("❌ Failed to create image destination, falling back to plain JPEG") + guard let jpegData = image.jpegData(compressionQuality: 0.9) else { + print("❌ Failed to convert image to JPEG") + throw CocoaError(.fileWriteUnknown) + } + try jpegData.write(to: fileURL) + self?.finishPhoto( + fileURL: fileURL, + eventClass: eventClass, + mediaMetadata: mediaMetadata, + captureLocation: captureLocation + ) return } - print("📸 Saving photo file...") - try jpegData.write(to: fileURL) - print("📸 Photo file saved successfully") + CGImageDestinationAddImage(destination, cgImage, imageProperties as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + print("❌ Failed to finalize image destination") + throw CocoaError(.fileWriteUnknown) + } + + print("📸 Photo file saved successfully with metadata") // Exclude from iCloud / iTunes backup var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true - try fileURL.setResourceValues(resourceValues) - - // Fire success event on main thread - var payload: [String: Any] = [ - "path": fileURL.path(percentEncoded: false), - "mimeType": "image/jpeg" - ] - if let id = self?.pendingPhotoId { - payload["id"] = id - } - - // Dispatch event with slight delay to ensure UI is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - LaravelBridge.shared.send?(eventClass, payload) - print("✅ Photo captured successfully: \(fileURL.path)") - } + var mutableURL = fileURL + try mutableURL.setResourceValues(resourceValues) + + self?.finishPhoto( + fileURL: fileURL, + eventClass: eventClass, + mediaMetadata: mediaMetadata, + captureLocation: captureLocation + ) } catch { print("❌ Saving photo failed: \(error)") @@ -470,12 +658,53 @@ final class CameraPhotoDelegate: NSObject, UIImagePickerControllerDelegate, UINa DispatchQueue.main.async { LaravelBridge.shared.send?(cancelEventClass, payload) } + + // Clean up + self?.pendingPhotoId = nil + self?.pendingPhotoEvent = nil } + } + } - // Clean up - self?.pendingPhotoId = nil - self?.pendingPhotoEvent = nil + /// Build and dispatch the PhotoTaken payload, attaching `takenAt`/`latitude`/`longitude` + /// derived from the capture metadata and/or location fix. Keys are omitted when unavailable. + private func finishPhoto( + fileURL: URL, + eventClass: String, + mediaMetadata: [String: Any], + captureLocation: CLLocation? + ) { + var payload: [String: Any] = [ + "path": fileURL.path(percentEncoded: false), + "mimeType": "image/jpeg" + ] + if let id = pendingPhotoId { + payload["id"] = id + } + + // takenAt: prefer Exif DateTimeOriginal, otherwise fall back to "now" + // (a freshly captured photo's capture time is the present moment). + let takenAtDate = CameraMetadata.dateTimeOriginal(from: mediaMetadata) ?? Date() + payload["takenAt"] = CameraMetadata.isoString(from: takenAtDate) + + // latitude/longitude: prefer GPS embedded in the capture metadata, then our CLLocation. + if let coordinate = CameraMetadata.coordinate(from: mediaMetadata) { + payload["latitude"] = coordinate.latitude + payload["longitude"] = coordinate.longitude + } else if let location = captureLocation { + payload["latitude"] = location.coordinate.latitude + payload["longitude"] = location.coordinate.longitude + } + + // Dispatch event with slight delay to ensure UI is ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + LaravelBridge.shared.send?(eventClass, payload) + print("✅ Photo captured successfully: \(fileURL.path)") } + + // Clean up + pendingPhotoId = nil + pendingPhotoEvent = nil } // User hit "Cancel" @@ -484,6 +713,9 @@ final class CameraPhotoDelegate: NSObject, UIImagePickerControllerDelegate, UINa print("⚠️ Photo capture cancelled") + // Stop gathering location since the capture flow is finished. + CameraLocationProvider.shared.stop() + // Always use the default cancel event let cancelEventClass = "Native\\Mobile\\Events\\Camera\\PhotoCancelled" @@ -511,6 +743,18 @@ final class CameraGalleryManager: NSObject { // Store id and event for callback pendingGalleryId = id pendingGalleryEvent = event + + // Request full Photo Library access so picker results carry `assetIdentifier`, + // which we use to recover each asset's creation date and GPS location. Without + // photo library access the picker strips GPS for privacy. + PHPhotoLibrary.requestAuthorization(for: .readWrite) { [weak self] _ in + DispatchQueue.main.async { + self?.presentPicker(mediaType: mediaType, multiple: multiple, maxItems: maxItems) + } + } + } + + private func presentPicker(mediaType: String, multiple: Bool, maxItems: Int) { guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), @@ -520,7 +764,8 @@ final class CameraGalleryManager: NSObject { return } - var configuration = PHPickerConfiguration() + // Back the picker with the shared photo library so results expose `assetIdentifier`. + var configuration = PHPickerConfiguration(photoLibrary: .shared()) // Set media type filter switch mediaType.lowercased() { @@ -588,18 +833,26 @@ extension CameraGalleryManager: PHPickerViewControllerDelegate { let eventClass = pendingGalleryEvent ?? "Native\\Mobile\\Events\\Gallery\\MediaSelected" let capturedId = pendingGalleryId + // Serialize appends to processedFiles since loadFileRepresentation completions + // run on arbitrary queues. + let appendQueue = DispatchQueue(label: "CameraGalleryManager.append") + for (index, result) in results.enumerated() { group.enter() + // Resolve creation date / location from the backing PHAsset (requires photo + // library access + an assetIdentifier). Best-effort: nil when unavailable. + let assetMetadata = self.assetMetadata(for: result.assetIdentifier) + // Try to get the file representation if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in defer { group.leave() } if let url = url { - self.copyFileToCache(url: url, index: index, type: "image") { fileInfo in + self.copyFileToCache(url: url, index: index, type: "image", assetMetadata: assetMetadata) { fileInfo in if let fileInfo = fileInfo { - processedFiles.append(fileInfo) + appendQueue.sync { processedFiles.append(fileInfo) } } } } @@ -609,9 +862,10 @@ extension CameraGalleryManager: PHPickerViewControllerDelegate { defer { group.leave() } if let url = url { - self.copyFileToCache(url: url, index: index, type: "video") { fileInfo in + // Videos retain their existing handling: no metadata keys attached. + self.copyFileToCache(url: url, index: index, type: "video", assetMetadata: nil) { fileInfo in if let fileInfo = fileInfo { - processedFiles.append(fileInfo) + appendQueue.sync { processedFiles.append(fileInfo) } } } } @@ -639,7 +893,34 @@ extension CameraGalleryManager: PHPickerViewControllerDelegate { } } - private func copyFileToCache(url: URL, index: Int, type: String, completion: @escaping ([String: Any]?) -> Void) { + /// Resolve `takenAt`/`latitude`/`longitude` for a picked result using its backing PHAsset. + /// Returns nil when there is no identifier or the asset can't be fetched (e.g. no access). + /// The returned dictionary only contains keys whose values are available. + private func assetMetadata(for identifier: String?) -> [String: Any]? { + guard let identifier = identifier else { + return nil + } + + let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil) + guard let asset = fetchResult.firstObject else { + return nil + } + + var metadata: [String: Any] = [:] + + if let creationDate = asset.creationDate { + metadata["takenAt"] = CameraMetadata.isoString(from: creationDate) + } + + if let location = asset.location { + metadata["latitude"] = location.coordinate.latitude + metadata["longitude"] = location.coordinate.longitude + } + + return metadata.isEmpty ? nil : metadata + } + + private func copyFileToCache(url: URL, index: Int, type: String, assetMetadata: [String: Any]?, completion: @escaping ([String: Any]?) -> Void) { let fileManager = FileManager.default // Use temporary directory with Gallery subfolder @@ -661,13 +942,19 @@ extension CameraGalleryManager: PHPickerViewControllerDelegate { try fileManager.copyItem(at: url, to: destinationURL) - let fileInfo: [String: Any] = [ + var fileInfo: [String: Any] = [ "path": destinationURL.path, "mimeType": getMimeType(for: fileExtension), "extension": fileExtension, "type": type ] + // Attach capture metadata for images (videos keep their existing shape). + // Only present keys are merged in, so unavailable values are omitted. + if let assetMetadata = assetMetadata { + fileInfo.merge(assetMetadata) { _, new in new } + } + completion(fileInfo) } catch { print("Error copying file: \(error)")