diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 51064705..c23e12f0 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -24,8 +24,9 @@ # UI_MAX_PARALLEL - simulators run at the same time (1 = serial) # # Optional repo secrets (Settings > Secrets and variables > Actions): -# KARTAVIEW_ACCESS_TOKEN, JAWG_ACCESS_TOKEN -> written to Secrets.xcconfig if it -# is missing on the runner; the build tolerates their absence (#include?). +# JAWG_ACCESS_TOKEN -> written to Secrets.xcconfig if it is missing on the +# runner; the build tolerates its absence (#include?). Photo-upload tests use a +# mock Panoramax server and need no token. name: iOS Tests @@ -118,16 +119,14 @@ jobs: - name: Write Secrets.xcconfig (only if missing) env: - KARTAVIEW_ACCESS_TOKEN: ${{ secrets.KARTAVIEW_ACCESS_TOKEN }} JAWG_ACCESS_TOKEN: ${{ secrets.JAWG_ACCESS_TOKEN }} run: | set -euo pipefail dest="GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig" if [ -f "$dest" ]; then echo "$dest already present - leaving it untouched." - elif [ -n "${KARTAVIEW_ACCESS_TOKEN:-}" ] || [ -n "${JAWG_ACCESS_TOKEN:-}" ]; then - printf 'KARTAVIEW_ACCESS_TOKEN = %s\nJAWG_ACCESS_TOKEN = %s\n' \ - "${KARTAVIEW_ACCESS_TOKEN:-}" "${JAWG_ACCESS_TOKEN:-}" > "$dest" + elif [ -n "${JAWG_ACCESS_TOKEN:-}" ]; then + printf 'JAWG_ACCESS_TOKEN = %s\n' "${JAWG_ACCESS_TOKEN:-}" > "$dest" echo "Wrote $dest from repository secrets." else echo "No $dest and no token secrets - Config.xcconfig #include? tolerates this." diff --git a/.gitignore b/.gitignore index cc4845d5..0b234907 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,5 @@ GoInfoGame/.DS_Store # Secrets (never commit) GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig +pointnmap-camera-teardown.patch +scripts/panoramax_demo.sh diff --git a/GoInfoGame/GoInfoGame.xcodeproj/project.pbxproj b/GoInfoGame/GoInfoGame.xcodeproj/project.pbxproj index 6b2d908a..fa1ab552 100644 --- a/GoInfoGame/GoInfoGame.xcodeproj/project.pbxproj +++ b/GoInfoGame/GoInfoGame.xcodeproj/project.pbxproj @@ -14,12 +14,6 @@ C7UNT0000000000000000034 /* QuestRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000033 /* QuestRepositoryTests.swift */; }; C7UNT0000000000000000032 /* QuestModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000031 /* QuestModelTests.swift */; }; C7UNT0000000000000000030 /* SubmissionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000029 /* SubmissionManagerTests.swift */; }; - C7UNT0000000000000000028 /* KartaviewStatusFailure.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000027 /* KartaviewStatusFailure.json */; }; - C7UNT0000000000000000026 /* KartaviewPhotoLookupEmpty.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000025 /* KartaviewPhotoLookupEmpty.json */; }; - C7UNT0000000000000000024 /* KartaviewPhotoLookup.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000023 /* KartaviewPhotoLookup.json */; }; - C7UNT0000000000000000022 /* KartaviewUploadFinished.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000021 /* KartaviewUploadFinished.json */; }; - C7UNT0000000000000000020 /* KartaviewPhotoUploaded.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000019 /* KartaviewPhotoUploaded.json */; }; - C7UNT0000000000000000018 /* KartaviewSequenceCreated.json in Resources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000017 /* KartaviewSequenceCreated.json */; }; C7UNT0000000000000000016 /* DatabaseConnectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000015 /* DatabaseConnectorTests.swift */; }; C7UNT0000000000000000014 /* MockServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000013 /* MockServer.swift */; }; C7UNT0000000000000000012 /* SessionAndLoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7UNT0000000000000000011 /* SessionAndLoginTests.swift */; }; @@ -41,7 +35,6 @@ 05DBBB622B164D9A00B6F110 /* RealOPElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05DBBB612B164D9A00B6F110 /* RealOPElement.swift */; }; 05DBBB642B17204300B6F110 /* RealmOPGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05DBBB632B17204300B6F110 /* RealmOPGeometry.swift */; }; 05DBBB662B17209600B6F110 /* RealmOPMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05DBBB652B17209600B6F110 /* RealmOPMeta.swift */; }; - 171574E02FD1CC4A00AA6603 /* PhotoLookupResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171574DF2FD1CC4A00AA6603 /* PhotoLookupResponse.swift */; }; 227FDA1227FD4BFA2E18876C /* QuestImageStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FA4EB22185846E113DA376 /* QuestImageStorage.swift */; }; 2E71655C971839F8DAF42E67 /* CompassButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CD36AA62170BAF936749E0 /* CompassButtonView.swift */; }; 342561966FC217124BD9CFE7 /* Pods_GoInfoGame_GoInfoGameUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D539E22EFF6636F8CBA035A /* Pods_GoInfoGame_GoInfoGameUITests.framework */; }; @@ -281,10 +274,8 @@ CFECB318461BB876B1621890 /* Pods_osmapi.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4F37DA661B22FA87DF8282F /* Pods_osmapi.framework */; }; DD224EE7B5FC852AF5637C95 /* ScreenshotOnFailureUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E70058F06D4E69B2E0669A8B /* ScreenshotOnFailureUITestCase.swift */; }; F45BA2B5418942E3B4C91667 /* StoredFeatureDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9C135AD8A342A1A94E757C /* StoredFeatureDraft.swift */; }; - FA18CAE22CC7CD89008247F2 /* KartaviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18CAE12CC7CD89008247F2 /* KartaviewViewModel.swift */; }; - FA18CAE52CC7D0DD008247F2 /* SequenceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18CAE42CC7D0DD008247F2 /* SequenceModel.swift */; }; - FA18CAE72CC80A34008247F2 /* UploadPhotoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18CAE62CC80A34008247F2 /* UploadPhotoModel.swift */; }; - FA18CAE92CCFD212008247F2 /* FinishUploadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18CAE82CCFD212008247F2 /* FinishUploadingModel.swift */; }; + B7A1C0DE2F10000100000004 /* PanoramaxUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A1C0DE2F10000100000002 /* PanoramaxUploader.swift */; }; + B7A1C0DE2F10000100000005 /* PhotoUploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A1C0DE2F10000100000003 /* PhotoUploadService.swift */; }; FA18CAEB2CD0F718008247F2 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18CAEA2CD0F718008247F2 /* CameraView.swift */; }; FA1992ED2BB1A78C003B4719 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1992EC2BB1A78C003B4719 /* KeychainManager.swift */; }; FA50718C2D54E08800CE9798 /* AddFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA50718B2D54E08800CE9798 /* AddFeatureView.swift */; }; @@ -426,12 +417,6 @@ C7UNT0000000000000000033 /* QuestRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuestRepositoryTests.swift; path = "QuestRepositoryTests.swift"; sourceTree = ""; }; C7UNT0000000000000000031 /* QuestModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QuestModelTests.swift; path = "QuestModelTests.swift"; sourceTree = ""; }; C7UNT0000000000000000029 /* SubmissionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SubmissionManagerTests.swift; path = "SubmissionManagerTests.swift"; sourceTree = ""; }; - C7UNT0000000000000000027 /* KartaviewStatusFailure.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewStatusFailure.json; path = "Fixtures/KartaviewStatusFailure.json"; sourceTree = ""; }; - C7UNT0000000000000000025 /* KartaviewPhotoLookupEmpty.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewPhotoLookupEmpty.json; path = "Fixtures/KartaviewPhotoLookupEmpty.json"; sourceTree = ""; }; - C7UNT0000000000000000023 /* KartaviewPhotoLookup.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewPhotoLookup.json; path = "Fixtures/KartaviewPhotoLookup.json"; sourceTree = ""; }; - C7UNT0000000000000000021 /* KartaviewUploadFinished.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewUploadFinished.json; path = "Fixtures/KartaviewUploadFinished.json"; sourceTree = ""; }; - C7UNT0000000000000000019 /* KartaviewPhotoUploaded.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewPhotoUploaded.json; path = "Fixtures/KartaviewPhotoUploaded.json"; sourceTree = ""; }; - C7UNT0000000000000000017 /* KartaviewSequenceCreated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = KartaviewSequenceCreated.json; path = "Fixtures/KartaviewSequenceCreated.json"; sourceTree = ""; }; C7UNT0000000000000000015 /* DatabaseConnectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DatabaseConnectorTests.swift; path = "DatabaseConnectorTests.swift"; sourceTree = ""; }; C7UNT0000000000000000013 /* MockServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MockServer.swift; path = "TestSupport/MockServer.swift"; sourceTree = ""; }; C7UNT0000000000000000011 /* SessionAndLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionAndLoginTests.swift; path = "SessionAndLoginTests.swift"; sourceTree = ""; }; @@ -457,7 +442,6 @@ 0644279A63026564F68E19C5 /* Pods-osmapi.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-osmapi.release.xcconfig"; path = "Target Support Files/Pods-osmapi/Pods-osmapi.release.xcconfig"; sourceTree = ""; }; 066F85ADCDCE7357368131F8 /* Pods_GoInfoGameTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GoInfoGameTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 16DDF6D11556A5A5F2F1C860 /* StoredNoteDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredNoteDraft.swift; sourceTree = ""; }; - 171574DF2FD1CC4A00AA6603 /* PhotoLookupResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLookupResponse.swift; sourceTree = ""; }; 188843AC2B5FA2DF0008DCF8 /* TactilePavingStepsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TactilePavingStepsTests.swift; sourceTree = ""; }; 188843AE2B5FB32D0008DCF8 /* WayLitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayLitTests.swift; sourceTree = ""; }; 18EC425B2B84987200DE05D7 /* TactilePavingCrosswalkForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TactilePavingCrosswalkForm.swift; sourceTree = ""; }; @@ -731,10 +715,8 @@ E82B8736511C2D879C41EDA1 /* Pods-osmapiTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-osmapiTests.release.xcconfig"; path = "Target Support Files/Pods-osmapiTests/Pods-osmapiTests.release.xcconfig"; sourceTree = ""; }; F008D431593E568E75C48C84 /* Pods-GoInfoGame.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GoInfoGame.debug.xcconfig"; path = "Target Support Files/Pods-GoInfoGame/Pods-GoInfoGame.debug.xcconfig"; sourceTree = ""; }; F7B0248DF7AD2506C0779CE9 /* QuestSubmissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestSubmissionManager.swift; sourceTree = ""; }; - FA18CAE12CC7CD89008247F2 /* KartaviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KartaviewViewModel.swift; sourceTree = ""; }; - FA18CAE42CC7D0DD008247F2 /* SequenceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceModel.swift; sourceTree = ""; }; - FA18CAE62CC80A34008247F2 /* UploadPhotoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPhotoModel.swift; sourceTree = ""; }; - FA18CAE82CCFD212008247F2 /* FinishUploadingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinishUploadingModel.swift; sourceTree = ""; }; + B7A1C0DE2F10000100000002 /* PanoramaxUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanoramaxUploader.swift; sourceTree = ""; }; + B7A1C0DE2F10000100000003 /* PhotoUploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoUploadService.swift; sourceTree = ""; }; FA18CAEA2CD0F718008247F2 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; FA1992EC2BB1A78C003B4719 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; FA50718B2D54E08800CE9798 /* AddFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFeatureView.swift; sourceTree = ""; }; @@ -1673,40 +1655,13 @@ path = MockResponses; sourceTree = ""; }; - FA18CADE2CC7CD46008247F2 /* Kartaview */ = { + B7A1C0DE2F10000100000001 /* PhotoUpload */ = { isa = PBXGroup; children = ( - FA18CAE32CC7CDAF008247F2 /* Model */, - FA18CAE02CC7CD61008247F2 /* ViewModel */, - FA18CADF2CC7CD5C008247F2 /* View */, + B7A1C0DE2F10000100000002 /* PanoramaxUploader.swift */, + B7A1C0DE2F10000100000003 /* PhotoUploadService.swift */, ); - path = Kartaview; - sourceTree = ""; - }; - FA18CADF2CC7CD5C008247F2 /* View */ = { - isa = PBXGroup; - children = ( - FA18CAEA2CD0F718008247F2 /* CameraView.swift */, - ); - path = View; - sourceTree = ""; - }; - FA18CAE02CC7CD61008247F2 /* ViewModel */ = { - isa = PBXGroup; - children = ( - FA18CAE12CC7CD89008247F2 /* KartaviewViewModel.swift */, - ); - path = ViewModel; - sourceTree = ""; - }; - FA18CAE32CC7CDAF008247F2 /* Model */ = { - isa = PBXGroup; - children = ( - FA18CAE42CC7D0DD008247F2 /* SequenceModel.swift */, - FA18CAE62CC80A34008247F2 /* UploadPhotoModel.swift */, - FA18CAE82CCFD212008247F2 /* FinishUploadingModel.swift */, - ); - path = Model; + path = PhotoUpload; sourceTree = ""; }; FA5853BF2B21E3E000301CDA /* Onboarding */ = { @@ -1821,6 +1776,7 @@ isa = PBXGroup; children = ( FABF3CFC2B8607860080EAC9 /* MeasureWidthContainer.swift */, + FA18CAEA2CD0F718008247F2 /* CameraView.swift */, FABF3CFF2B8607D80080EAC9 /* MeasureSidewalkView.swift */, ); path = Camera; @@ -1829,7 +1785,6 @@ FAD5C4E62AFCBE700040C61A = { isa = PBXGroup; children = ( - 171574DF2FD1CC4A00AA6603 /* PhotoLookupResponse.swift */, FAD5C4F12AFCBE700040C61A /* GoInfoGame */, FAD5C5082AFCBE720040C61A /* GoInfoGameTests */, FAD5C5122AFCBE720040C61A /* GoInfoGameUITests */, @@ -1863,7 +1818,7 @@ C7DBG00000000000000000A1 /* DebugSupport */, C78EEFAC2FDAD91000729240 /* PointNMap-Bridging-Header.h */, C787EF162E9676CC00BEF0A6 /* GoInfoGame.entitlements */, - FA18CADE2CC7CD46008247F2 /* Kartaview */, + B7A1C0DE2F10000100000001 /* PhotoUpload */, FAFDA2092C6F589900ECEAE9 /* UserProfile */, FAFDA1FB2C6D321500ECEAE9 /* Login */, FA8C74CF2C598F3D00D28220 /* Fonts */, @@ -1909,12 +1864,6 @@ C7UNT0000000000000000033 /* QuestRepositoryTests.swift */, C7UNT0000000000000000031 /* QuestModelTests.swift */, C7UNT0000000000000000029 /* SubmissionManagerTests.swift */, - C7UNT0000000000000000027 /* KartaviewStatusFailure.json */, - C7UNT0000000000000000025 /* KartaviewPhotoLookupEmpty.json */, - C7UNT0000000000000000023 /* KartaviewPhotoLookup.json */, - C7UNT0000000000000000021 /* KartaviewUploadFinished.json */, - C7UNT0000000000000000019 /* KartaviewPhotoUploaded.json */, - C7UNT0000000000000000017 /* KartaviewSequenceCreated.json */, C7UNT0000000000000000015 /* DatabaseConnectorTests.swift */, C7UNT0000000000000000013 /* MockServer.swift */, C7UNT0000000000000000011 /* SessionAndLoginTests.swift */, @@ -2364,12 +2313,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - C7UNT0000000000000000028 /* KartaviewStatusFailure.json in Resources */, - C7UNT0000000000000000026 /* KartaviewPhotoLookupEmpty.json in Resources */, - C7UNT0000000000000000024 /* KartaviewPhotoLookup.json in Resources */, - C7UNT0000000000000000022 /* KartaviewUploadFinished.json in Resources */, - C7UNT0000000000000000020 /* KartaviewPhotoUploaded.json in Resources */, - C7UNT0000000000000000018 /* KartaviewSequenceCreated.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2729,7 +2672,6 @@ 98E6B86BDDC74537ACA56927 /* LongFormElement+Completion.swift in Sources */, C79EDA8F2ED9977700D2B100 /* DottedLineView.swift in Sources */, C78E8BC42EDF2D55002215C0 /* EditedTagView.swift in Sources */, - FA18CAE72CC80A34008247F2 /* UploadPhotoModel.swift in Sources */, FAFDA1FA2C6B861A00ECEAE9 /* FileStorageManager.swift in Sources */, 70CFF415C62865D5A2F409AD /* NoteImageStorage.swift in Sources */, 4A89C70038014808B0E110A0 /* FeatureImageStorage.swift in Sources */, @@ -2775,7 +2717,8 @@ 973FC0112B4D2D1C00878269 /* QuestAndDescriptionView.swift in Sources */, FAFDA2042C6E8BEA00ECEAE9 /* APIEnvironment.swift in Sources */, 973FC0032B47DE0700878269 /* QuestsVM.swift in Sources */, - FA18CAE22CC7CD89008247F2 /* KartaviewViewModel.swift in Sources */, + B7A1C0DE2F10000100000004 /* PanoramaxUploader.swift in Sources */, + B7A1C0DE2F10000100000005 /* PhotoUploadService.swift in Sources */, 971575172B5FFD6F0044797C /* MapView.swift in Sources */, FAFDA2102C6F592700ECEAE9 /* UserProfileViewModel.swift in Sources */, FAE2584F2D39819300D2BB12 /* ManageQuestsView.swift in Sources */, @@ -2791,7 +2734,6 @@ A4B83AF32B5F9385006684CA /* StoredWay.swift in Sources */, C778A50F2EDD9C6D0014B9C5 /* UndoEditsViewModel.swift in Sources */, C787F3422E9CD34F00BEF0A6 /* ForceUpdateManager.swift in Sources */, - FA18CAE52CC7D0DD008247F2 /* SequenceModel.swift in Sources */, C71EB5AC2E2100510009E610 /* WMTSServer.swift in Sources */, C71EB5AD2E2100510009E610 /* SatellitePickerSheet.swift in Sources */, C71EB5AE2E2100510009E610 /* SatelliteServerResponse.swift in Sources */, @@ -2817,7 +2759,6 @@ C78E8BC22EDF20E6002215C0 /* UndoItemConfirmationView.swift in Sources */, 0536DD972B0BD95100B04C4B /* URLSession+Extension.swift in Sources */, 973FC04C2B5A5F5F00878269 /* RadioItem.swift in Sources */, - FA18CAE92CCFD212008247F2 /* FinishUploadingModel.swift in Sources */, C7ED070F2D70E2EC001FFFE2 /* TokenRefresher.swift in Sources */, C71EB5B62E2100E30009E610 /* ResponseHandler.swift in Sources */, C71EB5B72E2100E30009E610 /* NetworkManager.swift in Sources */, @@ -2846,7 +2787,6 @@ 971902112B983EFC009B3270 /* CustomMap.swift in Sources */, FA5071982D71F1B000CE9798 /* SyncLogger.swift in Sources */, A48037362BBA8DDC007EE7E4 /* WorkspacesResponse.swift in Sources */, - 171574E02FD1CC4A00AA6603 /* PhotoLookupResponse.swift in Sources */, C769AB552E431EFE00FA810A /* Strings+Generated.swift in Sources */, C73D2F792F98A0A800978D89 /* LiDARDetection.swift in Sources */, 0536DD992B0BDBFE00B04C4B /* OAuthManager.swift in Sources */, @@ -3380,7 +3320,7 @@ ); INFOPLIST_FILE = GoInfoGame/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "AVIV ScoutRoute"; - INFOPLIST_KEY_NSCameraUsageDescription = "This app requires access to your camera to upload images to Kartaview"; + INFOPLIST_KEY_NSCameraUsageDescription = "This app requires access to your camera to take photos of sidewalks and other features"; INFOPLIST_KEY_NSFaceIDUsageDescription = "Used to securely log in using Face ID"; INFOPLIST_KEY_NSFileProviderDomainUsageDescription = ""; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "We use your location to help you find the closest workspaces."; @@ -3456,7 +3396,7 @@ ); INFOPLIST_FILE = GoInfoGame/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "AVIV ScoutRoute"; - INFOPLIST_KEY_NSCameraUsageDescription = "This app requires access to your camera to upload images to Kartaview"; + INFOPLIST_KEY_NSCameraUsageDescription = "This app requires access to your camera to take photos of sidewalks and other features"; INFOPLIST_KEY_NSFaceIDUsageDescription = "Used to securely log in using Face ID"; INFOPLIST_KEY_NSFileProviderDomainUsageDescription = ""; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "We use your location to help you find the closest workspaces."; diff --git a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredChangeset.swift b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredChangeset.swift index 932f34d7..71992c00 100644 --- a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredChangeset.swift +++ b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredChangeset.swift @@ -52,13 +52,17 @@ class StoredChangeset: Object { /// this to delete the element on undo instead of restoring `originalTags`. @Persisted var isCreatedElement: Bool = false - /// A captured-but-not-yet-uploaded KartaView photo tied to this answer, saved to + /// A captured-but-not-yet-uploaded photo tied to this answer, saved to /// disk by `QuestImageStorage`. Non-nil until `QuestSubmissionManager` uploads it /// and writes the resulting URL into `tags[pendingImageTagKey]`, clearing both. @Persisted var pendingImagePath: String? /// Which tag key the uploaded photo's URL should be written to (e.g. - /// "ext:kartaview_url"). Only meaningful while `pendingImagePath` is non-nil. + /// "ext:street_images"). Only meaningful while `pendingImagePath` is non-nil. @Persisted var pendingImageTagKey: String? + /// Title of the question the pending photo was taken for, sent with the upload. + @Persisted var pendingImageQuestion: String? + /// `quest_id` of that question. + @Persisted var pendingImageQuestId: Int? @Persisted var retryCount: Int = 0 @Persisted var lastError: String? @@ -120,8 +124,19 @@ struct StoredChangesetSnapshot { let nodes: [Int64] let pendingImagePath: String? let pendingImageTagKey: String? + var pendingImageQuestion: String? = nil + var pendingImageQuestId: Int? = nil let retryCount: Int + /// OSM-style id ("way/123", "node/456") used to refer to this element from a photo. + var osmElementId: String? { + switch elementType { + case .way: return "way/\(elementId)" + case .node: return "node/\(elementId)" + case .unknown: return nil + } + } + func asOSMNode(tags overrideTags: [String: String]) -> OSMNode { var storage = originalTags for (key, value) in overrideTags { storage[key] = value } diff --git a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredFeatureDraft.swift b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredFeatureDraft.swift index 9c0037fa..9c7919f7 100644 --- a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredFeatureDraft.swift +++ b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredFeatureDraft.swift @@ -8,7 +8,7 @@ import RealmSwift import CoreLocation /// A feature (created via Add Feature, with any captured photos already saved to -/// disk) still waiting to be uploaded to Kartaview and created as an OSM node. +/// disk) still waiting to be uploaded to Panoramax and created as an OSM node. /// Presence of a row here means the feature is pending — the row is deleted once /// creation succeeds, which is what lets a feature captured offline (or interrupted /// mid-upload) survive an app kill and be retried later from the sync button or on @@ -24,6 +24,10 @@ class StoredFeatureDraft: Object { @Persisted var createdAt: Date = Date() @Persisted var lastError: String? @Persisted var retryCount: Int = 0 + /// Panoramax URL of each photo already uploaded, keyed by its index in `imagePaths` + /// ("0", "1", …; Realm map keys can't hold the "." of a file path), so a retry only + /// uploads the photos that haven't gone up yet. + @Persisted var uploadedPhotoURLs = Map() } /// Plain-value mirror of a StoredFeatureDraft. Realm objects are confined to the @@ -36,4 +40,8 @@ struct StoredFeatureDraftSnapshot { let tags: [String: String] let imagePaths: [String] let coordinates: CLLocationCoordinate2D + /// When the draft was saved, i.e. when its photos were taken. + var createdAt: Date = Date() + /// Photos already on Panoramax, keyed by their index in `imagePaths`. + var uploadedPhotoURLs: [Int: String] = [:] } diff --git a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredNoteDraft.swift b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredNoteDraft.swift index 24559aaa..241e8300 100644 --- a/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredNoteDraft.swift +++ b/GoInfoGame/GoInfoGame/DataBase/DBModels/StoredNoteDraft.swift @@ -8,7 +8,7 @@ import RealmSwift import CoreLocation /// A note (with any captured photos already saved to disk) still waiting to be -/// uploaded to Kartaview and submitted to the notes API. Presence of a row here +/// uploaded to Panoramax and submitted to the notes API. Presence of a row here /// means the note is pending — the row is deleted once submission succeeds, which /// is what lets a note captured offline (or interrupted mid-upload) survive an app /// kill and be retried later from the sync button or on next app launch. @@ -21,6 +21,10 @@ class StoredNoteDraft: Object { @Persisted var createdAt: Date = Date() @Persisted var lastError: String? @Persisted var retryCount: Int = 0 + /// Panoramax URL of each photo already uploaded, keyed by its index in `imagePaths` + /// ("0", "1", …; Realm map keys can't hold the "." of a file path), so a retry only + /// uploads the photos that haven't gone up yet. + @Persisted var uploadedPhotoURLs = Map() } /// Plain-value mirror of a StoredNoteDraft. Realm objects are confined to the thread/ @@ -31,4 +35,8 @@ struct StoredNoteDraftSnapshot { let noteText: String let imagePaths: [String] let coordinates: CLLocationCoordinate2D + /// When the draft was saved, i.e. when its photos were taken. + var createdAt: Date = Date() + /// Photos already on Panoramax, keyed by their index in `imagePaths`. + var uploadedPhotoURLs: [Int: String] = [:] } diff --git a/GoInfoGame/GoInfoGame/DataBase/DatabaseConnector.swift b/GoInfoGame/GoInfoGame/DataBase/DatabaseConnector.swift index 546e7ec2..ff4fcf61 100644 --- a/GoInfoGame/GoInfoGame/DataBase/DatabaseConnector.swift +++ b/GoInfoGame/GoInfoGame/DataBase/DatabaseConnector.swift @@ -384,7 +384,7 @@ class DatabaseConnector { - parameter tags [String:String] tags changed with this - Returns: An instance of `StoredChangeset` */ - func createChangeset(id:Int, questType: String, type: StoredElementEnum, originalTags:[String:String], tags:[String:String], version: Int, iconName: String, point: CLLocationCoordinate2D? = nil, nodes: List? = nil, pendingImagePath: String? = nil, pendingImageTagKey: String? = nil) -> StoredChangeset? { + func createChangeset(id:Int, questType: String, type: StoredElementEnum, originalTags:[String:String], tags:[String:String], version: Int, iconName: String, point: CLLocationCoordinate2D? = nil, nodes: List? = nil, pendingImagePath: String? = nil, pendingImageTagKey: String? = nil, pendingImageQuestion: String? = nil, pendingImageQuestId: Int? = nil) -> StoredChangeset? { let realm = try! Realm(configuration: RealmConfig.configuration) let storedChangeset = StoredChangeset() storedChangeset.elementId = id @@ -400,6 +400,8 @@ class DatabaseConnector { } storedChangeset.pendingImagePath = pendingImagePath storedChangeset.pendingImageTagKey = pendingImageTagKey + storedChangeset.pendingImageQuestion = pendingImageQuestion + storedChangeset.pendingImageQuestId = pendingImageQuestId storedChangeset.timestamp = String(Date().timeIntervalSince1970) for tag in tags { @@ -524,10 +526,14 @@ class DatabaseConnector { draft.latitude = coordinates.latitude draft.longitude = coordinates.longitude draft.createdAt = Date() + var saved = draft try! realm.write { - realm.add(draft, update: .modified) + saved = realm.create(StoredNoteDraft.self, value: draft, update: .modified) + // A resubmit brings new photos, so URLs uploaded for the old ones no longer + // apply. `.modified` merges maps rather than replacing them, hence the clear. + saved.uploadedPhotoURLs.removeAll() } - return draft + return saved } /// All notes still waiting to be uploaded/submitted, oldest first. @@ -540,7 +546,9 @@ class DatabaseConnector { id: $0.id, noteText: $0.noteText, imagePaths: Array($0.imagePaths), - coordinates: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + coordinates: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), + createdAt: $0.createdAt, + uploadedPhotoURLs: Self.photoURLsByIndex($0.uploadedPhotoURLs) ) } } @@ -564,6 +572,15 @@ class DatabaseConnector { } } + /// Records that one of the draft's photos is on Panoramax, so a retry skips it. + func attachNoteDraftPhotoURL(id: String, photoIndex: Int, url: String) { + let realm = try! Realm(configuration: RealmConfig.configuration) + guard let draft = realm.object(ofType: StoredNoteDraft.self, forPrimaryKey: id) else { return } + try! realm.write { + draft.uploadedPhotoURLs[String(photoIndex)] = url + } + } + func markNoteDraftFailed(id: String, error: String) { let realm = try! Realm(configuration: RealmConfig.configuration) guard let draft = realm.object(ofType: StoredNoteDraft.self, forPrimaryKey: id) else { return } @@ -573,6 +590,15 @@ class DatabaseConnector { } } + /// A draft's stored "0", "1", … photo URL keys as photo indexes. + private static func photoURLsByIndex(_ map: Map) -> [Int: String] { + var urls: [Int: String] = [:] + for (key, url) in map.toDictionary() { + if let index = Int(key) { urls[index] = url } + } + return urls + } + // MARK: - Feature drafts (offline queue for Add Feature) /// Creates the pending feature draft, or if `id` already exists (a retry of a @@ -591,10 +617,14 @@ class DatabaseConnector { draft.latitude = coordinates.latitude draft.longitude = coordinates.longitude draft.createdAt = Date() + var saved = draft try! realm.write { - realm.add(draft, update: .modified) + saved = realm.create(StoredFeatureDraft.self, value: draft, update: .modified) + // A resubmit brings new photos, so URLs uploaded for the old ones no longer + // apply. `.modified` merges maps rather than replacing them, hence the clear. + saved.uploadedPhotoURLs.removeAll() } - return draft + return saved } /// All features still waiting to be uploaded/created, oldest first. @@ -609,7 +639,9 @@ class DatabaseConnector { iconName: $0.iconName, tags: $0.tags.toDictionary(), imagePaths: Array($0.imagePaths), - coordinates: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + coordinates: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), + createdAt: $0.createdAt, + uploadedPhotoURLs: Self.photoURLsByIndex($0.uploadedPhotoURLs) ) } } @@ -633,6 +665,15 @@ class DatabaseConnector { } } + /// Records that one of the draft's photos is on Panoramax, so a retry skips it. + func attachFeatureDraftPhotoURL(id: String, photoIndex: Int, url: String) { + let realm = try! Realm(configuration: RealmConfig.configuration) + guard let draft = realm.object(ofType: StoredFeatureDraft.self, forPrimaryKey: id) else { return } + try! realm.write { + draft.uploadedPhotoURLs[String(photoIndex)] = url + } + } + func markFeatureDraftFailed(id: String, error: String) { let realm = try! Realm(configuration: RealmConfig.configuration) guard let draft = realm.object(ofType: StoredFeatureDraft.self, forPrimaryKey: id) else { return } @@ -667,6 +708,8 @@ class DatabaseConnector { nodes: Array($0.nodes), pendingImagePath: $0.pendingImagePath, pendingImageTagKey: $0.pendingImageTagKey, + pendingImageQuestion: $0.pendingImageQuestion, + pendingImageQuestId: $0.pendingImageQuestId, retryCount: $0.retryCount ) } @@ -683,6 +726,8 @@ class DatabaseConnector { changeset.tags.setValue(url, forKey: tagKey) changeset.pendingImagePath = nil changeset.pendingImageTagKey = nil + changeset.pendingImageQuestion = nil + changeset.pendingImageQuestId = nil } } @@ -720,6 +765,8 @@ class DatabaseConnector { nodes: Array(changeset.nodes), pendingImagePath: changeset.pendingImagePath, pendingImageTagKey: changeset.pendingImageTagKey, + pendingImageQuestion: changeset.pendingImageQuestion, + pendingImageQuestId: changeset.pendingImageQuestId, retryCount: changeset.retryCount ) } @@ -734,6 +781,8 @@ class DatabaseConnector { try! realm.write { changeset.pendingImagePath = nil changeset.pendingImageTagKey = nil + changeset.pendingImageQuestion = nil + changeset.pendingImageQuestId = nil } return oldPath } @@ -746,7 +795,12 @@ struct RealmConfig { // or have defaults, so no migration block content is needed for existing rows — // Realm fills them in automatically — but the version itself must move, or a // pre-existing installed schema-version-3 database won't match this class anymore. - static let configuration = Realm.Configuration(schemaVersion: 4) { migration, oldSchemaVersion in + // Bumped 4 -> 5 -> 6 for StoredChangeset.pendingImageQuestion/pendingImageQuestId + // (optional, no migration + // content needed). + // Bumped 6 -> 7 for StoredNoteDraft/StoredFeatureDraft.uploadedPhotoURLs (an empty + // map by default, no migration content needed). + static let configuration = Realm.Configuration(schemaVersion: 7) { migration, oldSchemaVersion in if oldSchemaVersion < 1 { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" diff --git a/GoInfoGame/GoInfoGame/FeatureImageStorage.swift b/GoInfoGame/GoInfoGame/FeatureImageStorage.swift index 15d5c6ed..40a2ee16 100644 --- a/GoInfoGame/GoInfoGame/FeatureImageStorage.swift +++ b/GoInfoGame/GoInfoGame/FeatureImageStorage.swift @@ -25,7 +25,8 @@ final class FeatureImageStorage { @discardableResult func save(_ image: UIImage) throws -> String { - guard let data = image.jpegData(compressionQuality: 0.9) else { + // Keeps the photo's capture heading (if any) in the file's EXIF for the upload. + guard let data = PhotoHeading.jpegData(for: image, compressionQuality: 0.9) else { throw NSError(domain: "FeatureImageStorage", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to encode photo"]) } let fileURL = directoryURL.appendingPathComponent("\(UUID().uuidString).jpg") @@ -34,7 +35,7 @@ final class FeatureImageStorage { } func load(path: String) -> UIImage? { - UIImage(contentsOfFile: path) + PhotoHeading.loadImage(atPath: path) } func delete(paths: [String]) { diff --git a/GoInfoGame/GoInfoGame/Info.plist b/GoInfoGame/GoInfoGame/Info.plist index 3b32d99e..146479ca 100644 --- a/GoInfoGame/GoInfoGame/Info.plist +++ b/GoInfoGame/GoInfoGame/Info.plist @@ -17,8 +17,18 @@ APP_FORCE_UPDATE_URL $(APP_FORCE_UPDATE_URL) - KARTAVIEW_ACCESS_TOKEN - $(KARTAVIEW_ACCESS_TOKEN) + PANORAMAX_BASE_URL_DEV + $(PANORAMAX_BASE_URL_DEV) + PANORAMAX_BASE_URL_STAGE + $(PANORAMAX_BASE_URL_STAGE) + PANORAMAX_BASE_URL_PROD + $(PANORAMAX_BASE_URL_PROD) + PANORAMAX_TOKEN_DEV + $(PANORAMAX_TOKEN_DEV) + PANORAMAX_TOKEN_STAGE + $(PANORAMAX_TOKEN_STAGE) + PANORAMAX_TOKEN_PROD + $(PANORAMAX_TOKEN_PROD) JAWG_ACCESS_TOKEN $(JAWG_ACCESS_TOKEN) AppStpre_URL diff --git a/GoInfoGame/GoInfoGame/Kartaview/Model/FinishUploadingModel.swift b/GoInfoGame/GoInfoGame/Kartaview/Model/FinishUploadingModel.swift deleted file mode 100644 index 37912d41..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/Model/FinishUploadingModel.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// FinishUploadingModel.swift -// GoInfoGame -// -// Created by Achyut Kumar M on 28/10/24. -// - -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let finishUploadingModel = try? JSONDecoder().decode(FinishUploadingModel.self, from: jsonData) - -import Foundation - -// MARK: - FinishUploadingModel -struct FinishUploadingModel: Codable { - let status: FUStatus - let osv: FUOsv -} - -// MARK: - Osv -struct FUOsv: Codable { - let sequenceID: String - - enum CodingKeys: String, CodingKey { - case sequenceID = "sequenceId" - } -} - -// MARK: - Status -struct FUStatus: Codable { - let apiCode: Int - let apiMessage: String - let httpCode: Int - let httpMessage: String -} diff --git a/GoInfoGame/GoInfoGame/Kartaview/Model/PhotoLookupResponse.swift b/GoInfoGame/GoInfoGame/Kartaview/Model/PhotoLookupResponse.swift deleted file mode 100644 index 07a16efd..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/Model/PhotoLookupResponse.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PhotoLookupResponse.swift -// GoInfoGame -// - -import Foundation - -// MARK: - PhotoLookupResponse -struct PhotoLookupResponse: Codable { - let result: PhotoLookupResult? -} - -// MARK: - Result -struct PhotoLookupResult: Codable { - let data: [PhotoLookupData]? -} - -// MARK: - Data -struct PhotoLookupData: Codable { - let imageLthUrl: String? -} diff --git a/GoInfoGame/GoInfoGame/Kartaview/Model/SequenceModel.swift b/GoInfoGame/GoInfoGame/Kartaview/Model/SequenceModel.swift deleted file mode 100644 index 134a9a48..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/Model/SequenceModel.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// SequenceModel.swift -// GoInfoGame -// -// Created by Achyut Kumar M on 22/10/24. -// - -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let sequenceViewModel = try? JSONDecoder().decode(SequenceViewModel.self, from: jsonData) - -import Foundation - -// MARK: - SequenceViewModel -struct SequenceModel: Codable { - let status: Status - let osv: Osv -} - -// MARK: - Osv -struct Osv: Codable { - let sequence: Sequence -} - -// MARK: - Sequence -struct Sequence: Codable { - let id, userID, dateAdded, currentLat: String - let currentLng: String - let countryCode, stateCode: JSONNull? - let status, imagesStatus, metaDataFilename: String - let detectedSignsFilename, clientTotal, clientTotalDetails, obdInfo: JSONNull? - let platformName, platformVersion, appVersion, track: JSONNull? - let matchTrack, reviewed, changes, recognitions: JSONNull? - let address, sequenceType, uploadSource, distance: JSONNull? - let processingStatus, countActivePhotos: String - - enum CodingKeys: String, CodingKey { - case id - case userID = "userId" - case dateAdded, currentLat, currentLng, countryCode, stateCode, status, imagesStatus, metaDataFilename, detectedSignsFilename, clientTotal, clientTotalDetails, obdInfo, platformName, platformVersion, appVersion, track, matchTrack, reviewed, changes, recognitions, address, sequenceType, uploadSource, distance, processingStatus, countActivePhotos - } -} - -// MARK: - Status -struct Status: Codable { - let apiCode: Int - let apiMessage: String - let httpCode: Int - let httpMessage: String -} - -// MARK: - Encode/decode helpers - -class JSONNull: Codable, Hashable { - - public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { - return true - } - - public var hashValue: Int { - return 0 - } - - public init() {} - - public required init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if !container.decodeNil() { - throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull")) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encodeNil() - } -} diff --git a/GoInfoGame/GoInfoGame/Kartaview/Model/UploadPhotoModel.swift b/GoInfoGame/GoInfoGame/Kartaview/Model/UploadPhotoModel.swift deleted file mode 100644 index 0566f84c..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/Model/UploadPhotoModel.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// UploadPhotoModel.swift -// GoInfoGame -// -// Created by Achyut Kumar M on 22/10/24. -// - -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let uploadPhotoModel = try? JSONDecoder().decode(UploadPhotoModel.self, from: jsonData) - -import Foundation - -// MARK: - UploadPhotoModel -struct UploadPhotoModel: Codable { - let status: PhotoStatus - let osv: PhotoOsv -} - -// MARK: - Osv -struct PhotoOsv: Codable { - let photo: Photo -} - -// MARK: - Photo -struct Photo: Codable { - let id, sequenceID, dateAdded, sequenceIndex: String - let photoName, lat, lng: String - let path: String - - enum CodingKeys: String, CodingKey { - case id - case sequenceID = "sequenceId" - case dateAdded, sequenceIndex, photoName, lat, lng, path - } -} - -// MARK: - Status -struct PhotoStatus: Codable { - let apiCode: Int - let apiMessage: String - let httpCode: Int - let httpMessage: String -} - diff --git a/GoInfoGame/GoInfoGame/Kartaview/View/CameraView.swift b/GoInfoGame/GoInfoGame/Kartaview/View/CameraView.swift deleted file mode 100644 index 23121491..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/View/CameraView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// CameraView.swift -// GoInfoGame -// -// Created by Achyut Kumar M on 29/10/24. -// - -import Foundation -import UIKit -import SwiftUI - -struct CameraView: UIViewControllerRepresentable { - @Binding var capturedImage: UIImage? - @Binding var isPresented: Bool - @Environment(\.presentationMode) var presentationMode - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.delegate = context.coordinator - return picker - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: CameraView - - init(_ parent: CameraView) { - self.parent = parent - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - if let image = info[.originalImage] as? UIImage { - parent.capturedImage = image - } - parent.isPresented = false // Close the camera view after capturing - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - parent.isPresented = false - } - } -} diff --git a/GoInfoGame/GoInfoGame/Kartaview/ViewModel/KartaviewViewModel.swift b/GoInfoGame/GoInfoGame/Kartaview/ViewModel/KartaviewViewModel.swift deleted file mode 100644 index 92d2e03d..00000000 --- a/GoInfoGame/GoInfoGame/Kartaview/ViewModel/KartaviewViewModel.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// KartaviewViewModel.swift -// GoInfoGame -// -// Created by Achyut Kumar M on 22/10/24. -// - -import Foundation -import UIKit -import CoreLocation - -class KartaviewViewModel: ObservableObject { - - @Published var sequenceId: String? - - var capturedImage: UIImage - - let locationManagerDelegate = LocationManagerDelegate() - - private var heading: String? - - private var location: CLLocationCoordinate2D? - - private let kartaViewAccessToken = Bundle.main.object(forInfoDictionaryKey: "KARTAVIEW_ACCESS_TOKEN") as? String ?? "" - - init(capturedImage: UIImage) { - self.capturedImage = capturedImage - - locationManagerDelegate.locationUpdateHandler = { [weak self] location in - guard let self = self else { return } - self.location = location - } - - locationManagerDelegate.headingUpdateHandler = { [weak self] heading in - guard let self = self else { return } - self.heading = "\(heading)" - locationManagerDelegate.stopUpdatingHeading() - } - - locationManagerDelegate.requestLocationAuthorization() - locationManagerDelegate.startUpdatingLocation() - locationManagerDelegate.startUpdatingHeading() - } - - // Step 1: Create Sequence - func createSequence(completion: @escaping (String, Bool) -> ()) { - let formData: [[String: Any]] = - [["key": "access_token", "value": kartaViewAccessToken, "type": "text"]] - - - ApiManager.shared.performRequest(to: .createKartaViewSequence(formData), setupType: .kartaview, modelType: SequenceModel.self) { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let success): - // Extract the sequenceId from the response model - if success.status.httpCode == 200 { - self.sequenceId = success.osv.sequence.id - print("Sequence created with ID: \(self.sequenceId ?? "")") - print("SEQUENCE CREATED ------> PROCEEDING TO UPLOAD PHOTO") - // Step 2: Upload Photo after receiving sequenceId - self.uploadPhoto(sequenceId: self.sequenceId!, completion: completion) - } else { - completion("Failed to create Kartaview sequence", false) - } - case .failure(let error): - print("Failed to create sequence: \(error.localizedDescription)") - completion(error.localizedDescription, false) - } - } - } - - func uploadPhoto(sequenceId: String, completion: @escaping (String, Bool) -> ()) { - guard let imageData = imageToData(image: capturedImage) else { - print("NO image found") - completion("Failed to process the captured photo", false) - return - } - - - let latitude = location?.latitude.description ?? "0.0" - let longitude = location?.longitude.description ?? "0.0" - - let formData: [[String: Any]] = [ - ["key": "access_token", "value": kartaViewAccessToken, "type": "text"], - [ - "key": "sequenceId", - "value": sequenceId, - "type": "text" - ], - [ - "key": "sequenceIndex", - "value": "1", - "type": "text" - ], - [ - "key": "coordinate", - "value": "\(latitude), \(longitude)", - "type": "text" - ], - [ - "key": "access_token", - "value": kartaViewAccessToken, - "type": "text" - ], - [ - "key": "photo", - "src": imageData, - "filename": "osmlogo.jpeg", - "type": "file" - ], - [ - "key": "headers", - "value": "\(heading ?? "0")", - "type": "text" - ] - - ] - - ApiManager.shared.performRequest(to: .uploadPhotoToKartaview(formData), setupType: .kartaview, modelType: UploadPhotoModel.self) { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let success): - let status = success.status.httpCode - if status == 200 { - print("PHOTO UPLOADED ----->>>> FINISHING SEQUENCE") - self.finishUploading(photoId: success.osv.photo.id, sequenceId: sequenceId, completion: completion) - } else { - completion("Failed to upload photo to Kartaview", false) - } - case .failure(let failure): - print("FAILED") - completion(failure.localizedDescription, false) - } - } - } - - func finishUploading(photoId: String, sequenceId: String, completion: @escaping (String, Bool) -> ()) { - - let formData: [[String: Any]] = [ - [ - "key": "sequenceId", - "value": sequenceId, - "type": "text" - ], - [ - "key": "access_token", - "value": kartaViewAccessToken, - "type": "text" - ], - ] - - ApiManager.shared.performRequest(to: .finshedUploadingToKartaview(formData), setupType: .kartaview, modelType: FinishUploadingModel.self) { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let success): - let status = success.status.httpCode - if status == 200 { - // Look the photo up by its own id — the authoritative per-photo - // identifier, returned in the upload response — to get KartaView's - // own hosted URL. Bounded to a couple of quick attempts; if it's - // still not queryable, this is a genuine failure (no locally-guessed - // URL is substituted) and is reported/logged as such. - self.fetchHostedPhotoUrl(photoId: photoId) { hostedUrl in - guard let hostedUrl = hostedUrl else { - print("KARTAVIEW PHOTO URL LOOKUP FAILED ----->>>> photoId: \(photoId), sequenceId: \(sequenceId)") - completion("Failed to retrieve photo URL", false) - return - } - print("PHOTO UPLOADED SUCCESSFULLY: \(hostedUrl)") - completion(hostedUrl, true) - } - } else { - completion("Failed to finish Kartaview upload", false) - } - case .failure(let failure): - print("FINISHING FAILED") - completion("An error occured", false) - } - } - } - - /// Looks up the hosted URL for a photo by its own id (not sequenceId+sequenceIndex — - /// that composite lookup was the source of the earlier "Failed to retrieve photo URL" - /// failures). Bounded retry budget; returns nil (never throws) if KartaView still - /// can't produce a URL, so the caller can report/log a genuine failure rather than - /// silently guessing a URL that may or may not be correct. - private func fetchHostedPhotoUrl(photoId: String, attempt: Int = 1, maxAttempts: Int = 2, completion: @escaping (String?) -> Void) { - ApiManager.shared.performRequest(to: .fetchPhotoUrlFromKartaview(photoId, kartaViewAccessToken), setupType: .kartaviewV2, modelType: PhotoLookupResponse.self) { [weak self] result in - guard let self = self else { return } - if case .success(let response) = result, let url = response.result?.data?.imageLthUrl { - completion(url) - return - } - if case .failure(let error) = result { - print("Photo id lookup failed (attempt \(attempt)/\(maxAttempts)) for photoId \(photoId): \(error.localizedDescription)") - } else { - print("Photo id lookup returned no URL (attempt \(attempt)/\(maxAttempts)) for photoId \(photoId)") - } - guard attempt < maxAttempts else { - completion(nil) - return - } - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.fetchHostedPhotoUrl(photoId: photoId, attempt: attempt + 1, maxAttempts: maxAttempts, completion: completion) - } - } - } - - - func imageToData(image: UIImage) -> Data? { - return image.jpegData(compressionQuality: 1.0) - } - - /// Runs the full create-sequence → upload-photo → finish → fetch-url chain and returns the hosted photo URL. - func uploadAsync() async throws -> String { - try await withCheckedThrowingContinuation { continuation in - createSequence { result, success in - if success { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: NSError(domain: "KartaviewUpload", code: 0, userInfo: [NSLocalizedDescriptionKey: result])) - } - } - } - } -} diff --git a/GoInfoGame/GoInfoGame/LocationManagerDelegate.swift b/GoInfoGame/GoInfoGame/LocationManagerDelegate.swift index 22b8561d..0622ca60 100644 --- a/GoInfoGame/GoInfoGame/LocationManagerDelegate.swift +++ b/GoInfoGame/GoInfoGame/LocationManagerDelegate.swift @@ -38,7 +38,7 @@ class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { // .fetchWorkspacesList(), gated on locationUpdateHandler - is then stuck forever. // Delivering one fixed coordinate directly removes the dependency on that // simulator state entirely, for every consumer of this class (InitialViewModel, - // MapViewModel, KartaviewViewModel, AccessibilityModeViewModel, CustomMap). + // MapViewModel, AccessibilityModeViewModel, CustomMap). // Async, matching CLLocationManager's own asynchronous delivery, so callers that // set locationUpdateHandler immediately before calling this (the normal pattern) // are not depending on synchronous delivery order. diff --git a/GoInfoGame/GoInfoGame/Network/APIConfiguration.swift b/GoInfoGame/GoInfoGame/Network/APIConfiguration.swift index eda3cb85..5ea8e063 100644 --- a/GoInfoGame/GoInfoGame/Network/APIConfiguration.swift +++ b/GoInfoGame/GoInfoGame/Network/APIConfiguration.swift @@ -40,12 +40,4 @@ class APIConfiguration { func userProfileUrl(for endpoint: APIEndpoint) -> URL? { return URL(string: environment.userProfileBaseURL + endpoint.path) } - - func kartaViewUrl(for endpoint: APIEndpoint) -> URL? { - return URL(string: environment.kartaViewBaseURL + endpoint.path) - } - - func kartaViewV2Url(for endpoint: APIEndpoint) -> URL? { - return URL(string: environment.kartaViewV2BaseURL + endpoint.path) } -} diff --git a/GoInfoGame/GoInfoGame/Network/APIEndPoint.swift b/GoInfoGame/GoInfoGame/Network/APIEndPoint.swift index 6da57abc..a7f1e939 100644 --- a/GoInfoGame/GoInfoGame/Network/APIEndPoint.swift +++ b/GoInfoGame/GoInfoGame/Network/APIEndPoint.swift @@ -118,26 +118,6 @@ struct APIEndpoint { return APIEndpoint(path: "/user-profile?user_name=\(userName)", method: "GET", body: nil, headers: header, formData: nil) } - static let createKartaViewSequence = { (formData: [[String: Any]]) in - return APIEndpoint(path: "/sequence/", method: "POST", body: nil, headers: nil, formData: formData) - } - - static let uploadPhotoToKartaview = {(formData: [[String: Any]]) in - return APIEndpoint(path: "/photo/", method: "POST", body: nil, headers: nil, formData: formData) - } - - static let fetchPhotoUrlFromKartaview = { (photoId: String, accessToken: String) in - var components = URLComponents() - components.queryItems = [URLQueryItem(name: "access_token", value: accessToken)] - let query = components.percentEncodedQuery.map { "?\($0)" } ?? "" - return APIEndpoint(path: "/photo/\(photoId)" + query, method: "GET", body: nil, headers: nil, formData: nil) - } - - static let finshedUploadingToKartaview = { (formData: [[String: Any]]) in - return APIEndpoint(path: "/sequence/finished-uploading/", method: "POST", body: nil, headers: nil, formData: formData) - - } - static let submitNote = { (note: String, accessToken: String, lat: Double, long: Double, workspaceId: String) in let header = [ "Authorization": "Bearer \(accessToken)", diff --git a/GoInfoGame/GoInfoGame/Network/APIEnvironment.swift b/GoInfoGame/GoInfoGame/Network/APIEnvironment.swift index 0641e865..a094ba25 100644 --- a/GoInfoGame/GoInfoGame/Network/APIEnvironment.swift +++ b/GoInfoGame/GoInfoGame/Network/APIEnvironment.swift @@ -56,24 +56,42 @@ enum APIEnvironment: String, CaseIterable { } } - var kartaViewBaseURL: String { - switch self { - case .development: - return "https://api.openstreetcam.org/1.0" - case .staging: - return "https://api.openstreetcam.org/1.0" - case .production: - return "https://api.openstreetcam.org/1.0" + /// Our own Panoramax instance for this environment, from `PANORAMAX_BASE_URL_DEV`, + /// `_STAGE` or `_PROD` in Secrets.xcconfig. The URL is saved in every photo's + /// `ext:street_images` tag, so each one must be a permanent hostname. + /// Falls back to the dev instance when the environment's key is not set. + var panoramaxBaseURL: String { panoramaxBaseURL(info: Bundle.main.infoDictionary) } + + /// The upload token for this environment's instance, from `PANORAMAX_TOKEN_DEV`, + /// `_STAGE` or `_PROD` in Secrets.xcconfig. Empty when it is not set. + var panoramaxToken: String { panoramaxToken(info: Bundle.main.infoDictionary) } + + func panoramaxBaseURL(info: [String: Any]?) -> String { + guard let url = Self.configValue("PANORAMAX_BASE_URL_\(configSuffix)", in: info) else { + return Self.panoramaxDevURL } + return url.hasSuffix("/") ? String(url.dropLast()) : url } - var kartaViewV2BaseURL: String { - switch self { - case .development, .staging, .production: - return "https://api.openstreetcam.org/2.0" - } + func panoramaxToken(info: [String: Any]?) -> String { + Self.configValue("PANORAMAX_TOKEN_\(configSuffix)", in: info) ?? "" } - + + /// Suffix of this environment's keys in Secrets.xcconfig: DEV, STAGE or PROD. + private var configSuffix: String { rawValue.uppercased() } + + /// A build setting passed through Info.plist, or nil when it is empty or was never + /// defined (Xcode then leaves the `$(KEY)` placeholder or an empty string). + private static func configValue(_ key: String, in info: [String: Any]?) -> String? { + guard let value = (info?[key] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, !value.hasPrefix("$(") else { return nil } + return value + } + + // Used when Secrets.xcconfig has no URL for the environment (e.g. CI). Azure's default + // hostname: move to a permanent custom domain before production photos are written. + private static let panoramaxDevURL = "https://panoramax-dev-faaadghgcfgwcchk.westus2-01.azurewebsites.net" + func displayString() -> String { switch self { case .development: diff --git a/GoInfoGame/GoInfoGame/Network/ApiManager.swift b/GoInfoGame/GoInfoGame/Network/ApiManager.swift index 729ca175..2fa3492e 100644 --- a/GoInfoGame/GoInfoGame/Network/ApiManager.swift +++ b/GoInfoGame/GoInfoGame/Network/ApiManager.swift @@ -18,8 +18,6 @@ enum SetupType { case login case osm case userProfile - case kartaview - case kartaviewV2 } // Singleton object that deals with APIs @@ -58,10 +56,6 @@ class ApiManager { finalUrl = APIConfiguration.shared.osmUrl(for: endpoint) case .userProfile: finalUrl = APIConfiguration.shared.userProfileUrl(for: endpoint) - case .kartaview: - finalUrl = APIConfiguration.shared.kartaViewUrl(for: endpoint) - case .kartaviewV2: - finalUrl = APIConfiguration.shared.kartaViewV2Url(for: endpoint) } debugPrint("URL prepared \(Date())") guard let url = finalUrl else { diff --git a/GoInfoGame/GoInfoGame/NoteImageStorage.swift b/GoInfoGame/GoInfoGame/NoteImageStorage.swift index e4768a70..7b169022 100644 --- a/GoInfoGame/GoInfoGame/NoteImageStorage.swift +++ b/GoInfoGame/GoInfoGame/NoteImageStorage.swift @@ -23,7 +23,8 @@ final class NoteImageStorage { @discardableResult func save(_ image: UIImage) throws -> String { - guard let data = image.jpegData(compressionQuality: 0.9) else { + // Keeps the photo's capture heading (if any) in the file's EXIF for the upload. + guard let data = PhotoHeading.jpegData(for: image, compressionQuality: 0.9) else { throw NSError(domain: "NoteImageStorage", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to encode photo"]) } let fileURL = directoryURL.appendingPathComponent("\(UUID().uuidString).jpg") @@ -32,7 +33,7 @@ final class NoteImageStorage { } func load(path: String) -> UIImage? { - UIImage(contentsOfFile: path) + PhotoHeading.loadImage(atPath: path) } func delete(paths: [String]) { diff --git a/GoInfoGame/GoInfoGame/PhotoUpload/PanoramaxUploader.swift b/GoInfoGame/GoInfoGame/PhotoUpload/PanoramaxUploader.swift new file mode 100644 index 00000000..6f79976b --- /dev/null +++ b/GoInfoGame/GoInfoGame/PhotoUpload/PanoramaxUploader.swift @@ -0,0 +1,252 @@ +// +// PanoramaxUploader.swift +// GoInfoGame +// + +import Foundation +import CoreLocation + +enum PanoramaxError: LocalizedError, Equatable { + case missingToken + case missingLocation + case invalidImage + case invalidResponse + case rejected(String) + case server(status: Int, message: String) + + var errorDescription: String? { + switch self { + case .missingToken: + return "Panoramax token is not configured" + case .missingLocation: + return "Photo location is unknown" + case .invalidImage: + return "Failed to process the captured photo" + case .invalidResponse: + return "Unexpected response from Panoramax" + case .rejected(let reason): + return "Panoramax rejected the photo: \(reason)" + case .server(let status, let message): + return "Panoramax error \(status): \(message)" + } + } +} + +/// Uploads one photo to a Panoramax instance and returns its hosted URL. +/// +/// Flow (https://docs.panoramax.fr/backend/api/api/): create an upload set for a single +/// file, then upload the JPEG with its location and capture time. The upload set completes +/// on its own once that one file arrives. The picture id comes back immediately, but the +/// photo is only viewable once the server has finished blurring it (seconds to minutes). +struct PanoramaxUploader { + + let baseURL: String + let token: String + var session: URLSession = .shared + + /// Defaults to the instance and token of the app's current environment (dev, stage or + /// prod), both set in Secrets.xcconfig. + init(baseURL: String = APIConfiguration.shared.environment.panoramaxBaseURL, + token: String = APIConfiguration.shared.environment.panoramaxToken, + session: URLSession = .shared) { + self.baseURL = baseURL + self.token = token + self.session = session + } + + /// `semantics` are Panoramax semantic tags (`[["key": ..., "value": ...]]`) stored on the + /// picture, e.g. from `PhotoUploadContext.semantics`. + /// `heading` is the compass direction the camera faced (degrees from true north), sent + /// as the picture's `GPSImgDirection` when known. + func upload(imageData: Data, coordinate: CLLocationCoordinate2D, capturedAt: Date = Date(), + heading: Double? = nil, semantics: [[String: String]] = []) async throws -> String { + guard !token.isEmpty else { throw PanoramaxError.missingToken } + let uploadSetId = try await createUploadSet() + let pictureId = try await uploadFile(imageData, to: uploadSetId, coordinate: coordinate, + capturedAt: capturedAt, heading: heading, semantics: semantics) + return pictureURL(for: pictureId) + } + + func pictureURL(for pictureId: String) -> String { + "\(baseURL)/api/pictures/\(pictureId)/hd.jpg" + } + + /// The picture id in a URL from `pictureURL(for:)` on this instance (`hd`, `sd` or + /// `thumb`), or nil for any other URL, such as an old KartaView photo or a picture on + /// another environment's instance. + func pictureId(fromURL urlString: String) -> String? { + let prefix = "\(baseURL)/api/pictures/" + guard urlString.hasPrefix(prefix) else { return nil } + let parts = urlString.dropFirst(prefix.count).split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, ["hd.jpg", "sd.jpg", "thumb.jpg"].contains(parts[1]) else { return nil } + return String(parts[0]) + } + + /// Adds semantic tags to a picture that is already on Panoramax, e.g. the element id of + /// a new feature, which is only known once the element is created after the photo upload. + /// Tags are added, never replaced (a key can hold several values on Panoramax). + /// Panoramax answers 404 until the picture has been dispatched to a collection, a few + /// seconds after its upload set completes, so callers should retry that. + func addSemantics(_ semantics: [[String: String]], toPicture pictureId: String) async throws { + guard !token.isEmpty else { throw PanoramaxError.missingToken } + guard !semantics.isEmpty else { return } + let payload: [String: Any] = ["semantics": semantics.map { $0.merging(["action": "add"]) { current, _ in current } }] + var request = try makeRequest(path: "/api/pictures/\(pictureId)", method: "PATCH") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys, .withoutEscapingSlashes]) + // 304: the picture already has these tags, e.g. a retry after an earlier success. + _ = try await send(request, accepting: [200, 304]) + } + + // MARK: - Steps + + private func createUploadSet() async throws -> String { + let payload: [String: Any] = [ + "title": "AVIV ScoutRoute", + "estimated_nb_files": 1, + // Every photo is its own upload: don't split it into sequences or drop it as a + // duplicate of an earlier photo taken at the same spot. + "no_split": true, + "no_deduplication": true, + "visibility": "anyone" + ] + var request = try makeRequest(path: "/api/upload_sets") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let data = try await send(request, accepting: [200, 201]) + guard let uploadSet = try? JSONDecoder().decode(UploadSetResponse.self, from: data) else { + throw PanoramaxError.invalidResponse + } + return uploadSet.id + } + + private func uploadFile(_ imageData: Data, to uploadSetId: String, coordinate: CLLocationCoordinate2D, + capturedAt: Date, heading: Double?, semantics: [[String: String]]) async throws -> String { + let boundary = "Boundary-\(UUID().uuidString)" + var request = try makeRequest(path: "/api/upload_sets/\(uploadSetId)/files") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Panoramax rejects photos without GPS and capture time, and our JPEG carries no + // EXIF, so both are always sent as override fields. + var body = Data() + var fields = [ + "override_latitude": "\(coordinate.latitude)", + "override_longitude": "\(coordinate.longitude)", + "override_capture_time": ISO8601DateFormatter().string(from: capturedAt) + ] + fields.merge(PhotoHeading.overrideFields(for: heading)) { current, _ in current } + if !semantics.isEmpty { + let json = try JSONSerialization.data(withJSONObject: semantics, options: [.sortedKeys, .withoutEscapingSlashes]) + fields["semantics"] = String(decoding: json, as: UTF8.self) + } + for (name, value) in fields { + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n") + } + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(UUID().uuidString).jpg\"\r\n") + body.append("Content-Type: image/jpeg\r\n\r\n") + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n") + request.httpBody = body + + let (data, status) = try await sendReturningStatus(request, accepting: [200, 201, 202, 409]) + if status == 409 { + // The server already has this exact image (same bytes), e.g. a retry after the app + // was killed between the upload and saving its URL. Unless the instance sets + // API_ACCEPT_DUPLICATE, it refuses a second copy, so reuse the existing picture. + guard let existingId = DuplicateResponse.existingPictureId(in: data) else { + throw PanoramaxError.server(status: status, message: Self.message(in: data, status: status)) + } + return existingId + } + guard let file = try? JSONDecoder().decode(UploadedFileResponse.self, from: data) else { + throw PanoramaxError.invalidResponse + } + // A rejected photo still comes back as 202, with the reason in the body. + if let rejected = file.rejected { + throw PanoramaxError.rejected(rejected.message ?? rejected.reason) + } + guard let pictureId = file.pictureId else { throw PanoramaxError.invalidResponse } + return pictureId + } + + // MARK: - HTTP + + private func makeRequest(path: String, method: String = "POST") throws -> URLRequest { + guard let url = URL(string: baseURL + path) else { throw PanoramaxError.invalidResponse } + var request = URLRequest(url: url, timeoutInterval: 120) + request.httpMethod = method + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private func send(_ request: URLRequest, accepting statuses: Set) async throws -> Data { + try await sendReturningStatus(request, accepting: statuses).data + } + + private func sendReturningStatus(_ request: URLRequest, accepting statuses: Set) async throws -> (data: Data, status: Int) { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw PanoramaxError.invalidResponse } + guard statuses.contains(http.statusCode) else { + throw PanoramaxError.server(status: http.statusCode, message: Self.message(in: data, status: http.statusCode)) + } + return (data, http.statusCode) + } + + private static func message(in data: Data, status: Int) -> String { + (try? JSONDecoder().decode(ErrorResponse.self, from: data))?.message + ?? HTTPURLResponse.localizedString(forStatusCode: status) + } +} + +// MARK: - Response models + +private struct UploadSetResponse: Decodable { + let id: String +} + +private struct UploadedFileResponse: Decodable { + let pictureId: String? + let rejected: Rejection? + + struct Rejection: Decodable { + let reason: String + let message: String? + } + + enum CodingKeys: String, CodingKey { + case pictureId = "picture_id" + case rejected + } +} + +private struct ErrorResponse: Decodable { + let message: String +} + +/// Panoramax's 409 for an image it already has. Same upload set: +/// `{"existing_item": {"id": ""}}`. An earlier upload set: +/// `{"upload_sets": [{"existing_item_id": "", "upload_set_id": "..."}]}`. +/// Read leniently, at the top level or under `details`, since only the payload is documented. +enum DuplicateResponse { + static func existingPictureId(in data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + for object in [json, json["details"] as? [String: Any]].compactMap({ $0 }) { + if let id = (object["existing_item"] as? [String: Any])?["id"] as? String, !id.isEmpty { + return id + } + if let sets = object["upload_sets"] as? [[String: Any]], + let id = sets.lazy.compactMap({ $0["existing_item_id"] as? String }).first(where: { !$0.isEmpty }) { + return id + } + } + return nil + } +} + +private extension Data { + mutating func append(_ string: String) { + append(Data(string.utf8)) + } +} diff --git a/GoInfoGame/GoInfoGame/PhotoUpload/PhotoUploadService.swift b/GoInfoGame/GoInfoGame/PhotoUpload/PhotoUploadService.swift new file mode 100644 index 00000000..034a3ee6 --- /dev/null +++ b/GoInfoGame/GoInfoGame/PhotoUpload/PhotoUploadService.swift @@ -0,0 +1,237 @@ +// +// PhotoUploadService.swift +// GoInfoGame +// + +import Foundation +import UIKit +import CoreLocation +import ImageIO +import UniformTypeIdentifiers + +/// OSM tags that hold an element's photo URL. New photos go to Panoramax and are written +/// to `ext:street_images`. `ext:kartaview_url` is read-only: photos uploaded to KartaView +/// before the switch keep showing until the photo is replaced or removed, which clears it. +enum PhotoTag { + /// Where every new photo's URL is written. + static let streetImages = "ext:street_images" + /// Legacy KartaView photos. Never written, only shown and cleared. + static let kartaview = "ext:kartaview_url" + + /// Newest first: when an element has both, the street image wins. + static let all = [streetImages, kartaview] + + /// The element's current photo URL, if it has one under any photo tag. + static func existingURL(in tags: [String: String]?) -> String? { + all.lazy.compactMap { tags?[$0] }.first { !$0.isEmpty } + } + + /// The photo tags that currently hold a URL on the element. + static func keysWithPhoto(in tags: [String: String]?) -> [String] { + all.filter { !(tags?[$0] ?? "").isEmpty } + } + + /// A small version of a photo URL for previews. A Panoramax picture URL + /// (`…/api/pictures//hd.jpg` or `sd.jpg`) becomes its 500px-wide `thumb.jpg`; any + /// other URL, such as an old KartaView photo, is returned unchanged. The tag itself + /// keeps the full-size URL. + static func thumbnailURL(for urlString: String) -> String { + guard var components = URLComponents(string: urlString) else { return urlString } + var parts = components.path.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count >= 4, + parts[parts.count - 3] == "pictures", parts[parts.count - 4] == "api", + ["hd.jpg", "sd.jpg"].contains(parts[parts.count - 1]) else { return urlString } + parts[parts.count - 1] = "thumb.jpg" + components.path = parts.joined(separator: "/") + return components.string ?? urlString + } +} + +/// The compass direction the camera faced when a photo was taken (degrees from true north, +/// 0..<360). It is attached to the captured `UIImage`, written into the JPEG's EXIF when the +/// photo is queued on disk, read back when the queue loads it, and sent to Panoramax as +/// `GPSImgDirection` so the photo shows which way it looks. +enum PhotoHeading { + + /// Normalizes to 0..<360, or nil for an invalid (negative / non-finite) reading. + static func normalized(_ degrees: Double?) -> Double? { + guard let degrees, degrees.isFinite, degrees >= 0 else { return nil } + let value = degrees.truncatingRemainder(dividingBy: 360) + return value + } + + /// Panoramax EXIF overrides, in the same form the Android app sends: a whole-degree + /// rational and "T" for true north. Empty when the heading is unknown. + static func overrideFields(for heading: Double?) -> [String: String] { + guard let heading = normalized(heading) else { return [:] } + let degrees = Int(heading.rounded()) % 360 + return [ + "override_Exif.GPSInfo.GPSImgDirection": "\(degrees)/1", + "override_Exif.GPSInfo.GPSImgDirectionRef": "T" + ] + } + + /// JPEG data for an image, with its capture heading in the EXIF GPS block when it has one. + static func jpegData(for image: UIImage, compressionQuality: CGFloat) -> Data? { + guard let plain = image.jpegData(compressionQuality: compressionQuality) else { return nil } + guard let heading = normalized(image.captureHeading), + let source = CGImageSourceCreateWithData(plain as CFData, nil) else { return plain } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(output, UTType.jpeg.identifier as CFString, 1, nil) else { + return plain + } + let properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: compressionQuality, + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSImgDirection: heading, + kCGImagePropertyGPSImgDirectionRef: "T" + ] + ] + CGImageDestinationAddImageFromSource(destination, source, 0, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { return plain } + return output as Data + } + + /// The capture heading stored in a JPEG file's EXIF, if any. + static func heading(inFileAt path: String) -> Double? { + guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any] else { return nil } + return normalized((gps[kCGImagePropertyGPSImgDirection] as? NSNumber)?.doubleValue) + } + + /// Loads a queued photo from disk with its capture heading attached. + static func loadImage(atPath path: String) -> UIImage? { + guard let image = UIImage(contentsOfFile: path) else { return nil } + image.captureHeading = heading(inFileAt: path) + return image + } +} + +private var captureHeadingKey: UInt8 = 0 + +extension UIImage { + /// See `PhotoHeading`. Set by `CameraView` when the photo is taken. + var captureHeading: Double? { + get { (objc_getAssociatedObject(self, &captureHeadingKey) as? NSNumber)?.doubleValue } + set { objc_setAssociatedObject(self, &captureHeadingKey, newValue.map { NSNumber(value: $0) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } +} + +/// Who and what a photo was taken for. Panoramax stores these as the picture's semantic +/// tags, so a photo can be traced back to its workspace, element and question later. +/// Semantics are public on the +/// picture, so nothing that identifies the user (such as their email) belongs here. +struct PhotoUploadContext: Equatable { + var workspaceId: String? + /// OSM-style id, e.g. "way/123" or "node/456". + var elementId: String? + var question: String? + var questId: Int? + /// Which flow took the photo, so photos can be told apart and searched later. + var source: Source? = nil + /// The preset of a new element (e.g. "Bench"); its element id isn't known at upload time. + var featureType: String? = nil + + enum Source: String { + case quest + case note + case newFeature = "new_feature" + } + + /// The current workspace. Element and question are added by the caller when the + /// photo belongs to one. + static func current(source: Source? = nil, elementId: String? = nil, question: String? = nil, + questId: Int? = nil, featureType: String? = nil) -> PhotoUploadContext { + PhotoUploadContext( + workspaceId: KeychainManager.load(key: "workspaceID"), + elementId: elementId, + question: question, + questId: questId, + source: source, + featureType: featureType + ) + } + + /// Panoramax semantics: one key/value pair per known field, empty ones left out. + var semantics: [[String: String]] { + [("workspace_id", workspaceId), ("element_id", elementId), ("quest_id", questId.map(String.init)), + ("question", question), ("source", source?.rawValue), ("feature_type", featureType)] + .compactMap { key, value in + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return ["key": key, "value": value] + } + } +} + +/// The one place quest answers, notes and new features upload their photos. Returns the +/// hosted photo URL, which callers put in the element's tag or the note text. +enum PhotoUploadService { + + /// Replaceable so tests can point uploads at a stub instance with a test token. + static var makePanoramaxUploader: () -> PanoramaxUploader = { PanoramaxUploader() } + + /// Uploads to Panoramax. `coordinate` is where the photo was taken (the element, note + /// or new feature); the device location is used when it is unknown. `capturedAt` is when + /// the photo was taken, which for a queued offline draft is earlier than the upload. + static func upload(_ image: UIImage, coordinate: CLLocationCoordinate2D?, capturedAt: Date = Date(), + context: PhotoUploadContext = .current()) async throws -> String { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { throw PanoramaxError.invalidImage } + let location = try await photoLocation(coordinate) + return try await makePanoramaxUploader().upload(imageData: imageData, coordinate: location, + capturedAt: capturedAt, heading: image.captureHeading, + semantics: context.semantics) + } + + /// Waits before each try of `tagPhotos`. The first try is immediate; Panoramax answers + /// 404 until a picture is dispatched to a collection, so later tries wait for that. + /// Replaceable so tests don't wait. + static var semanticsRetryDelays: [TimeInterval] = [0, 5, 30, 120] + + /// Adds `element_id` to photos that were uploaded before their element existed (a new + /// feature's photos), so they can be traced to it the same way quest photos are. + /// Best effort: the element is already created, so a failure is logged, never surfaced. + /// Returns the URLs that were tagged. + @discardableResult + static func tagPhotos(_ urls: [String], withElementId elementId: String) async -> [String] { + let uploader = makePanoramaxUploader() + let semantics = PhotoUploadContext(elementId: elementId).semantics + var tagged: [String] = [] + for url in urls { + // Skip photos not on this instance (e.g. uploaded before an environment switch). + guard let pictureId = uploader.pictureId(fromURL: url) else { continue } + if await addSemanticsRetrying(semantics, to: pictureId, with: uploader) { + tagged.append(url) + } + } + return tagged + } + + private static func addSemanticsRetrying(_ semantics: [[String: String]], to pictureId: String, + with uploader: PanoramaxUploader) async -> Bool { + for delay in semanticsRetryDelays { + if delay > 0 { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + do { + try await uploader.addSemantics(semantics, toPicture: pictureId) + return true + } catch PanoramaxError.server(let status, let message) where [401, 403].contains(status) { + print("Panoramax: not allowed to tag picture \(pictureId): \(message)") + return false // retrying won't help + } catch PanoramaxError.missingToken { + return false + } catch { + print("Panoramax: tagging picture \(pictureId) failed, may retry: \(error.localizedDescription)") + } + } + return false + } + + private static func photoLocation(_ coordinate: CLLocationCoordinate2D?) async throws -> CLLocationCoordinate2D { + if let coordinate, CLLocationCoordinate2DIsValid(coordinate), coordinate.latitude != 0 || coordinate.longitude != 0 { + return coordinate + } + let deviceLocation = await MainActor.run { CLLocationManager().location?.coordinate } + guard let deviceLocation else { throw PanoramaxError.missingLocation } + return deviceLocation + } +} diff --git a/GoInfoGame/GoInfoGame/QuestImageStorage.swift b/GoInfoGame/GoInfoGame/QuestImageStorage.swift index fa959bcf..ad5785b9 100644 --- a/GoInfoGame/GoInfoGame/QuestImageStorage.swift +++ b/GoInfoGame/GoInfoGame/QuestImageStorage.swift @@ -25,7 +25,8 @@ final class QuestImageStorage { @discardableResult func save(_ image: UIImage) throws -> String { - guard let data = image.jpegData(compressionQuality: 0.9) else { + // Keeps the photo's capture heading (if any) in the file's EXIF for the upload. + guard let data = PhotoHeading.jpegData(for: image, compressionQuality: 0.9) else { throw NSError(domain: "QuestImageStorage", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to encode photo"]) } let fileURL = directoryURL.appendingPathComponent("\(UUID().uuidString).jpg") @@ -34,7 +35,7 @@ final class QuestImageStorage { } func load(path: String) -> UIImage? { - UIImage(contentsOfFile: path) + PhotoHeading.loadImage(atPath: path) } func delete(paths: [String]) { diff --git a/GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig.example b/GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig.example index 3585fa10..77c9b40c 100644 --- a/GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig.example +++ b/GoInfoGame/GoInfoGame/Resources/Secrets.xcconfig.example @@ -6,5 +6,14 @@ // Secrets.xcconfig is included by Config.xcconfig and must never be committed. // -KARTAVIEW_ACCESS_TOKEN = your_kartaview_access_token_here +// Panoramax, one instance per app environment (Development, Staging, Production). +// Write // as /$()/ because // starts an xcconfig comment. +// A missing base URL falls back to the dev instance built into APIEnvironment. +PANORAMAX_BASE_URL_DEV = https:/$()/your-dev-panoramax.example.com +PANORAMAX_BASE_URL_STAGE = https:/$()/your-stage-panoramax.example.com +PANORAMAX_BASE_URL_PROD = https:/$()/your-prod-panoramax.example.com +// Upload tokens (JWT) for each instance, created and claimed with scripts/panoramax_demo.sh token +PANORAMAX_TOKEN_DEV = your_dev_panoramax_token_here +PANORAMAX_TOKEN_STAGE = your_stage_panoramax_token_here +PANORAMAX_TOKEN_PROD = your_prod_panoramax_token_here JAWG_ACCESS_TOKEN = your_jawg_access_token_here diff --git a/GoInfoGame/GoInfoGame/UI/Camera/CameraView.swift b/GoInfoGame/GoInfoGame/UI/Camera/CameraView.swift new file mode 100644 index 00000000..467b8039 --- /dev/null +++ b/GoInfoGame/GoInfoGame/UI/Camera/CameraView.swift @@ -0,0 +1,141 @@ +// +// CameraView.swift +// GoInfoGame +// +// Created by Achyut Kumar M on 29/10/24. +// + +import Foundation +import UIKit +import SwiftUI +import CoreLocation + +struct CameraView: UIViewControllerRepresentable { + @Binding var capturedImage: UIImage? + @Binding var isPresented: Bool + @Environment(\.presentationMode) var presentationMode + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + context.coordinator.headingTracker.start() + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } + + static func dismantleUIViewController(_ uiViewController: UIImagePickerController, coordinator: Coordinator) { + coordinator.headingTracker.stop() + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraView + /// Follows the compass while the camera is open, so the photo can record which + /// way it was taken (sent to Panoramax as GPSImgDirection). + let headingTracker = HeadingTracker() + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let image = info[.originalImage] as? UIImage { + image.captureHeading = headingTracker.headingAtShutter + parent.capturedImage = image + } + headingTracker.stop() + parent.isPresented = false // Close the camera view after capturing + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + headingTracker.stop() + parent.isPresented = false + } + } +} + +/// The device's true-north compass heading while the camera is open. +/// +/// Core Location reports the direction the back camera faces when the phone is held upright, +/// and `headingOrientation` keeps that right in landscape. The heading is frozen when the +/// shutter is pressed (UIImagePickerController posts "_UIImagePickerControllerUserDidCaptureItem"), +/// so turning the phone on the Use Photo screen doesn't change it. If that notification never +/// comes, the latest reading is used instead. +final class HeadingTracker: NSObject, CLLocationManagerDelegate { + + private let manager = CLLocationManager() + private var latestHeading: Double? + private var frozenHeading: Double? + private var observers: [NSObjectProtocol] = [] + + /// The heading when the shutter was pressed, else the latest one; nil when no + /// trustworthy reading was available (no compass, uncalibrated, location off). + var headingAtShutter: Double? { frozenHeading ?? latestHeading } + + override init() { + super.init() + manager.delegate = self + manager.headingFilter = 1 + } + + func start() { + guard CLLocationManager.headingAvailable(), observers.isEmpty else { return } + latestHeading = nil + frozenHeading = nil + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + updateHeadingOrientation() + let center = NotificationCenter.default + observers.append(center.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, + queue: .main) { [weak self] _ in + self?.updateHeadingOrientation() + }) + observers.append(center.addObserver(forName: Notification.Name("_UIImagePickerControllerUserDidCaptureItem"), + object: nil, queue: .main) { [weak self] _ in + self?.frozenHeading = self?.latestHeading + }) + observers.append(center.addObserver(forName: Notification.Name("_UIImagePickerControllerUserDidRejectItem"), + object: nil, queue: .main) { [weak self] _ in + self?.frozenHeading = nil // "Retake": the next shutter press decides again + }) + manager.startUpdatingHeading() + } + + func stop() { + guard !observers.isEmpty else { return } + manager.stopUpdatingHeading() + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + UIDevice.current.endGeneratingDeviceOrientationNotifications() + } + + deinit { + stop() + } + + private func updateHeadingOrientation() { + // Face up/down and unknown keep the last useful orientation. + switch UIDevice.current.orientation { + case .portrait: manager.headingOrientation = .portrait + case .portraitUpsideDown: manager.headingOrientation = .portraitUpsideDown + case .landscapeLeft: manager.headingOrientation = .landscapeLeft + case .landscapeRight: manager.headingOrientation = .landscapeRight + default: break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + // trueHeading is negative when it can't be worked out (e.g. no location fix), and a + // negative accuracy means the reading is invalid; send nothing rather than a guess. + guard newHeading.headingAccuracy >= 0, newHeading.trueHeading >= 0 else { return } + latestHeading = PhotoHeading.normalized(newHeading.trueHeading) + } + + func locationManagerShouldDisplayHeadingCalibration(_ manager: CLLocationManager) -> Bool { + false // never cover the camera with the calibration prompt + } +} diff --git a/GoInfoGame/GoInfoGame/UI/Map/FeatureSubmissionManager.swift b/GoInfoGame/GoInfoGame/UI/Map/FeatureSubmissionManager.swift index 716f9cb2..e237d099 100644 --- a/GoInfoGame/GoInfoGame/UI/Map/FeatureSubmissionManager.swift +++ b/GoInfoGame/GoInfoGame/UI/Map/FeatureSubmissionManager.swift @@ -22,7 +22,7 @@ struct FeatureDraft { var coordinates: CLLocationCoordinate2D } -/// Creates features (and their Kartaview photo uploads) outside the lifetime of +/// Creates features (and their photo uploads) outside the lifetime of /// FeatureSubmissionView, so the sheet can dismiss immediately after Submit is tapped — /// mirrors NotesSubmissionManager's role for Create Note. Every draft is persisted /// (photos on disk, metadata in Realm) *before* any network call, so a feature captured @@ -76,12 +76,9 @@ enum FeatureSubmissionManager { fileprivate static func attempt(_ record: StoredFeatureDraftSnapshot) async { do { var tags = record.tags - let images = record.imagePaths.compactMap { FeatureImageStorage.shared.load(path: $0) } - if !images.isEmpty { - let photoURLs = try await uploadPhotos(images) - for (index, url) in photoURLs.enumerated() { - tags["ext:image_url\(index + 1)"] = url - } + let photoURLs = try await uploadPhotos(for: record) + for (index, url) in photoURLs.enumerated() { + tags["ext:image_url\(index + 1)"] = url } let node = UserNodesHelper.getPowerPole( @@ -92,6 +89,16 @@ enum FeatureSubmissionManager { ) let created = try await DatasyncManager.shared.createNode(node: node) + // The photos went up before the node existed, so add its id to them now, like a + // quest photo's element_id. Detached: it may wait for Panoramax to dispatch the + // pictures, and must not hold up the rest of the queue. + if !photoURLs.isEmpty { + let elementId = "node/\(created.id)" + Task.detached(priority: .utility) { + await PhotoUploadService.tagPhotos(photoURLs, withElementId: elementId) + } + } + // Persist locally under the server's real id/version (never returned any // other way — see DatasyncManager.uploadNode), so the element can be // matched against LongForm quest_query filters and correctly submitted as @@ -126,24 +133,29 @@ enum FeatureSubmissionManager { } } - private static func uploadPhotos(_ images: [UIImage]) async throws -> [String] { + /// Uploads the draft's photos in order and returns their URLs. Each URL is saved on the + /// draft as soon as its photo is up, so when a later photo or the element itself fails, the + /// retry reuses it instead of putting the same photo on Panoramax again. + private static func uploadPhotos(for record: StoredFeatureDraftSnapshot) async throws -> [String] { var urls: [String] = [] - for (index, image) in images.enumerated() { + for (index, path) in record.imagePaths.enumerated() { + if let url = record.uploadedPhotoURLs[index] { + urls.append(url) + continue + } + guard let image = FeatureImageStorage.shared.load(path: path) else { continue } do { - // KartaviewViewModel creates a CLLocationManager on init and immediately - // calls requestWhenInUseAuthorization()/startUpdating*() — CoreLocation - // requires those to happen on a thread with an active run loop (main), - // or the app-level authorization handshake stalls. Only construction - // needs the hop; the network upload itself is fine off the main thread. - let viewModel = await MainActor.run { KartaviewViewModel(capturedImage: image) } - let url = try await viewModel.uploadAsync() + let url = try await PhotoUploadService.upload(image, coordinate: record.coordinates, + capturedAt: record.createdAt, + context: .current(source: .newFeature, featureType: record.presetName)) + DatabaseConnector.shared.attachFeatureDraftPhotoURL(id: record.id, photoIndex: index, url: url) urls.append(url) } catch { throw NSError( - domain: "KartaviewUpload", + domain: "PhotoUpload", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Failed to upload photo \(index + 1) to Kartaview: \(error.localizedDescription)"] + userInfo: [NSLocalizedDescriptionKey: "Failed to upload photo \(index + 1) to Panoramax: \(error.localizedDescription)"] ) } } diff --git a/GoInfoGame/GoInfoGame/UI/Map/MapView.swift b/GoInfoGame/GoInfoGame/UI/Map/MapView.swift index f12b4bc0..3aad90ed 100644 --- a/GoInfoGame/GoInfoGame/UI/Map/MapView.swift +++ b/GoInfoGame/GoInfoGame/UI/Map/MapView.swift @@ -1270,7 +1270,7 @@ public enum SheetDismissalScenario { case featuresQueueUpdated case featuresSyncing case featuresSyncFinished - /// A queued quest answer's photo upload to KartaView failed (the answer itself + /// A queued quest answer's photo upload to Panoramax failed (the answer itself /// stays queued and will retry automatically) — surfaced to the user since a /// silently-retrying photo upload is more likely to hide a real, recurring /// problem than a generic tag-sync retry would. diff --git a/GoInfoGame/GoInfoGame/UI/Map/NotesSubmissionManager.swift b/GoInfoGame/GoInfoGame/UI/Map/NotesSubmissionManager.swift index 413bffe6..2268956f 100644 --- a/GoInfoGame/GoInfoGame/UI/Map/NotesSubmissionManager.swift +++ b/GoInfoGame/GoInfoGame/UI/Map/NotesSubmissionManager.swift @@ -16,7 +16,7 @@ public struct NoteDraft { var coordinates: CLLocationCoordinate2D } -/// Submits notes (and their Kartaview photo uploads) outside the lifetime of +/// Submits notes (and their photo uploads) outside the lifetime of /// CreateNoteView, so the sheet can dismiss immediately after Submit is tapped. /// Every draft is persisted (text + photos on disk, metadata in Realm) *before* any /// network call, so a note captured offline — or one whose upload gets interrupted — @@ -63,11 +63,10 @@ enum NotesSubmissionManager { /// (already reflected in the sync badge) and is retried by the next trigger. fileprivate static func attempt(_ record: StoredNoteDraftSnapshot) async { do { - let images = record.imagePaths.compactMap { NoteImageStorage.shared.load(path: $0) } var finalNoteText = record.noteText - if !images.isEmpty { - let photoURLs = try await uploadPhotos(images) + let photoURLs = try await uploadPhotos(for: record) + if !photoURLs.isEmpty { finalNoteText += "\n\n" + photoURLs.joined(separator: "\n") } @@ -91,24 +90,29 @@ enum NotesSubmissionManager { } } - private static func uploadPhotos(_ images: [UIImage]) async throws -> [String] { + /// Uploads the draft's photos in order and returns their URLs. Each URL is saved on the + /// draft as soon as its photo is up, so when a later photo or the note itself fails, the + /// retry reuses it instead of putting the same photo on Panoramax again. + private static func uploadPhotos(for record: StoredNoteDraftSnapshot) async throws -> [String] { var urls: [String] = [] - for (index, image) in images.enumerated() { + for (index, path) in record.imagePaths.enumerated() { + if let url = record.uploadedPhotoURLs[index] { + urls.append(url) + continue + } + guard let image = NoteImageStorage.shared.load(path: path) else { continue } do { - // KartaviewViewModel creates a CLLocationManager on init and immediately - // calls requestWhenInUseAuthorization()/startUpdating*() — CoreLocation - // requires those to happen on a thread with an active run loop (main), - // or the app-level authorization handshake stalls. Only construction - // needs the hop; the network upload itself is fine off the main thread. - let viewModel = await MainActor.run { KartaviewViewModel(capturedImage: image) } - let url = try await viewModel.uploadAsync() + let url = try await PhotoUploadService.upload(image, coordinate: record.coordinates, + capturedAt: record.createdAt, + context: .current(source: .note)) + DatabaseConnector.shared.attachNoteDraftPhotoURL(id: record.id, photoIndex: index, url: url) urls.append(url) } catch { throw NSError( - domain: "KartaviewUpload", + domain: "PhotoUpload", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Failed to upload photo \(index + 1) to Kartaview: \(error.localizedDescription)"] + userInfo: [NSLocalizedDescriptionKey: "Failed to upload photo \(index + 1) to Panoramax: \(error.localizedDescription)"] ) } } diff --git a/GoInfoGame/GoInfoGame/UI/Map/QuestSubmissionManager.swift b/GoInfoGame/GoInfoGame/UI/Map/QuestSubmissionManager.swift index 580d2083..6959cdb5 100644 --- a/GoInfoGame/GoInfoGame/UI/Map/QuestSubmissionManager.swift +++ b/GoInfoGame/GoInfoGame/UI/Map/QuestSubmissionManager.swift @@ -8,7 +8,7 @@ import UIKit import CoreLocation import osmparser -/// Submits quest answers (and any attached KartaView photo) outside the lifetime of +/// Submits quest answers (and any attached photo) outside the lifetime of /// LongForm, so Submit can return immediately whether or not there's connectivity. /// Every answer is persisted (photo on disk, tags/element info in Realm, via the same /// `StoredChangeset` row `QuestProtocols.updateTags` already wrote before this queue @@ -22,12 +22,15 @@ enum QuestSubmissionManager { /// whole pending queue (not just this answer), so any earlier answers that failed /// get swept up too. static func submit(id: Int64, questType: String, tags: [String: String], type: ElementType, - iconName: String, capturedImage: UIImage?, imageTagKey: String?) { + iconName: String, capturedImage: UIImage?, imageTagKey: String?, + imageQuestion: String? = nil, imageQuestId: Int? = nil) { let storedElementType: StoredElementEnum = type == .way ? .way : .node Task.detached(priority: .userInitiated) { let pendingImagePath: String? = capturedImage.flatMap { try? QuestImageStorage.shared.save($0) } let pendingImageTagKey = pendingImagePath != nil ? imageTagKey : nil + let pendingImageQuestion = pendingImagePath != nil ? imageQuestion : nil + let pendingImageQuestId = pendingImagePath != nil ? imageQuestId : nil switch storedElementType { case .way: @@ -35,7 +38,8 @@ enum QuestSubmissionManager { DatabaseConnector.shared.createChangeset( id: Int(id), questType: questType, type: storedElementType, originalTags: way.tags.toDictionary(), tags: tags, version: way.version, iconName: iconName, - nodes: way.nodes, pendingImagePath: pendingImagePath, pendingImageTagKey: pendingImageTagKey + nodes: way.nodes, pendingImagePath: pendingImagePath, pendingImageTagKey: pendingImageTagKey, + pendingImageQuestion: pendingImageQuestion, pendingImageQuestId: pendingImageQuestId ) case .node: guard let node = DatabaseConnector.shared.getNode(id: Int(id)) else { return } @@ -43,7 +47,8 @@ enum QuestSubmissionManager { id: Int(id), questType: questType, type: storedElementType, originalTags: node.tags.toDictionary(), tags: tags, version: node.version, iconName: iconName, point: CLLocationCoordinate2D(latitude: node.latitude, longitude: node.longitude), - pendingImagePath: pendingImagePath, pendingImageTagKey: pendingImageTagKey + pendingImagePath: pendingImagePath, pendingImageTagKey: pendingImageTagKey, + pendingImageQuestion: pendingImageQuestion, pendingImageQuestId: pendingImageQuestId ) case .unknown: print("Unknown Stored element type received") @@ -94,13 +99,20 @@ enum QuestSubmissionManager { do { var tags = record.tags - if let path = record.pendingImagePath, let tagKey = record.pendingImageTagKey { + if let path = record.pendingImagePath, let storedTagKey = record.pendingImageTagKey { + // Every photo now goes to Panoramax, so one queued by an older version + // under `ext:kartaview_url` is written to `ext:street_images` instead. + let tagKey = PhotoTag.all.contains(storedTagKey) ? PhotoTag.streetImages : storedTagKey guard let image = QuestImageStorage.shared.load(path: path) else { DatabaseConnector.shared.markChangesetFailed(id: record.id, error: "Saved photo could not be read") return } do { - let url = try await uploadPhoto(image) + let context = PhotoUploadContext.current(source: .quest, elementId: record.osmElementId, + question: record.pendingImageQuestion, + questId: record.pendingImageQuestId) + let url = try await PhotoUploadService.upload(image, coordinate: photoCoordinate(for: record), + context: context) tags[tagKey] = url DatabaseConnector.shared.attachUploadedPhotoURL(id: record.id, tagKey: tagKey, url: url) QuestImageStorage.shared.delete(paths: [path]) @@ -146,15 +158,12 @@ enum QuestSubmissionManager { } } - private static func uploadPhoto(_ image: UIImage) async throws -> String { - // KartaviewViewModel creates a CLLocationManager on init and immediately calls - // requestWhenInUseAuthorization()/startUpdating*() — CoreLocation requires - // those to happen on a thread with an active run loop (main), or the app-level - // authorization handshake stalls. Only construction needs the hop; the network - // upload itself is fine off the main thread. Mirrors FeatureSubmissionManager/ - // NotesSubmissionManager's identical uploadPhotos helper. - let viewModel = await MainActor.run { KartaviewViewModel(capturedImage: image) } - return try await viewModel.uploadAsync() + /// Where the photo was taken: the node itself, or a way's first node. + private static func photoCoordinate(for record: StoredChangesetSnapshot) -> CLLocationCoordinate2D? { + if record.elementType == .node { return record.point } + guard let firstNodeId = record.nodes.first, + let node = DatabaseConnector.shared.getNode(id: Int(firstNodeId)) else { return nil } + return CLLocationCoordinate2D(latitude: node.latitude, longitude: node.longitude) } } diff --git a/GoInfoGame/GoInfoGame/quests/LongQuests/Classes/LongElementQuest.swift b/GoInfoGame/GoInfoGame/quests/LongQuests/Classes/LongElementQuest.swift index c705add4..86dbf534 100644 --- a/GoInfoGame/GoInfoGame/quests/LongQuests/Classes/LongElementQuest.swift +++ b/GoInfoGame/GoInfoGame/quests/LongQuests/Classes/LongElementQuest.swift @@ -236,7 +236,8 @@ class LongElementQuest: QuestBase, Quest { func onAnswer(answer: LongFormAnswer) { self.updateTags(id: id, questType: elementType, tags: answer.tags, type: type, iconName: iconName, - capturedImage: answer.capturedImage, imageTagKey: answer.imageTagKey) + capturedImage: answer.capturedImage, imageTagKey: answer.imageTagKey, + imageQuestion: answer.imageQuestion, imageQuestId: answer.imageQuestId) } func fetchLatestTagsIfNeeded() async -> (tags: [String: String], timestamp: Date)? { diff --git a/GoInfoGame/GoInfoGame/quests/LongQuests/View/LongForm.swift b/GoInfoGame/GoInfoGame/quests/LongQuests/View/LongForm.swift index 9ea060c8..c987800f 100644 --- a/GoInfoGame/GoInfoGame/quests/LongQuests/View/LongForm.swift +++ b/GoInfoGame/GoInfoGame/quests/LongQuests/View/LongForm.swift @@ -21,7 +21,7 @@ enum LongFormActiveAlert: Identifiable { /// LongForm's answer payload. Unlike every other quest form (which submits a bare /// `[String:String]`), LongForm can also carry a captured-but-not-yet-uploaded -/// KartaView photo — the upload itself is deferred to `QuestSubmissionManager`'s +/// photo — the upload itself is deferred to `QuestSubmissionManager`'s /// offline queue rather than attempted here, so Submit never has to wait on the /// network (see QuestSubmissionManager.swift). This is LongForm's own `AnswerClass`; /// no other quest form is affected by it. @@ -29,6 +29,10 @@ struct LongFormAnswer { let tags: [String: String] let capturedImage: UIImage? let imageTagKey: String? + /// Title of the question whose photo prompt was used, sent with the photo upload. + var imageQuestion: String? = nil + /// `quest_id` of that question. + var imageQuestId: Int? = nil } struct LongForm: View, QuestForm { @@ -74,9 +78,13 @@ struct LongForm: View, QuestForm { @State private var isCameraPresented = false @State private var capturedImage: UIImage? + /// The question whose "take a photo" prompt opened the camera, if any. Kept when the + /// photo is retaken from its card. + @State private var photoQuestion: String? + @State private var photoQuestId: Int? /// True once the user has marked the previously-synced photo - /// (`tags?["ext:kartaview_url"]`) for removal — drives the "PendingRemoval" card + /// (`PhotoTag.existingURL(in: tags)`) for removal — drives the "PendingRemoval" card /// state ("Photo will be removed" / undo), not an immediate hide. Purely local UI /// state until Submit: nothing is changed server-side until then, and the user can /// undo back to "Uploaded" any time before submitting. @@ -176,7 +184,11 @@ struct LongForm: View, QuestForm { if viewModel.shouldShowQuest(quest) { if canShowQuest(quest) { LongQuestView(quest: quest, selectedChoice: binding(for: quest), uploadPhoto: { result in - if result { isCameraPresented = true } + if result { + photoQuestion = quest.questTitle + photoQuestId = quest.questID + isCameraPresented = true + } }, hasPhotoAttached: hasPhotoAttached) } } @@ -364,10 +376,11 @@ struct LongForm: View, QuestForm { } /// Whether there's an existing, previously-synced photo for this element - /// (`tags?["ext:kartaview_url"]`) — used both to pick the right card state and to - /// choose the right "Pending" subtitle (plain upload vs. replace-warning). + /// (`ext:street_images`, or the older `ext:kartaview_url`) — used both to pick the + /// right card state and to choose the right "Pending" subtitle (plain upload vs. + /// replace-warning). private var hasExistingPhoto: Bool { - !(tags?["ext:kartaview_url"] ?? "").isEmpty + PhotoTag.existingURL(in: tags) != nil } /// True whenever `photoCard` below is showing a card at all — captured this @@ -405,7 +418,7 @@ struct LongForm: View, QuestForm { .clipShape(RoundedRectangle(cornerRadius: 8)) .clipped() } - } else if hasExistingPhoto, let existingPhotoURL = tags?["ext:kartaview_url"] { + } else if let existingPhotoURL = PhotoTag.existingURL(in: tags) { if dismissedExistingPhoto { photoCardRow( caption: "Photo will be removed", @@ -414,7 +427,7 @@ struct LongForm: View, QuestForm { badgeAction: { dismissedExistingPhoto = false }, badgeAccessibilityLabel: "Undo removal" ) { - LongFormImageView(urlString: existingPhotoURL, width: 56, height: 56) + LongFormImageView(urlString: PhotoTag.thumbnailURL(for: existingPhotoURL), width: 56, height: 56) .clipShape(RoundedRectangle(cornerRadius: 8)) .opacity(0.5) } @@ -428,7 +441,7 @@ struct LongForm: View, QuestForm { badgeAction: { dismissedExistingPhoto = true }, badgeAccessibilityLabel: "Remove photo" ) { - LongFormImageView(urlString: existingPhotoURL, width: 56, height: 56) + LongFormImageView(urlString: PhotoTag.thumbnailURL(for: existingPhotoURL), width: 56, height: 56) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -658,8 +671,14 @@ struct LongForm: View, QuestForm { // (see OSMNode/OSMWay.toPayload's empty-value skip). Without this, // dismissing the preview only hid it locally; the stale tag would // still be there after Submit. - if capturedImage == nil, dismissedExistingPhoto, let existing = tags?["ext:kartaview_url"], !existing.isEmpty { - answersToSubmit["ext:kartaview_url"] = "" + // A new photo goes to Panoramax under `ext:street_images`; an older + // KartaView photo (`ext:kartaview_url`) is dropped so the element doesn't + // keep two. Removing the photo clears every photo tag it has. + let photoTagKey = PhotoTag.streetImages + if capturedImage != nil || dismissedExistingPhoto { + for key in PhotoTag.keysWithPhoto(in: tags) where capturedImage == nil || key != photoTagKey { + answersToSubmit[key] = "" + } } // The photo (if any) is handed off unuploaded — QuestSubmissionManager's // offline queue uploads it and merges the URL into the tags in the @@ -667,7 +686,9 @@ struct LongForm: View, QuestForm { action(LongFormAnswer( tags: answersToSubmit, capturedImage: capturedImage, - imageTagKey: capturedImage != nil ? "ext:kartaview_url" : nil + imageTagKey: capturedImage != nil ? photoTagKey : nil, + imageQuestion: capturedImage != nil ? photoQuestion : nil, + imageQuestId: capturedImage != nil ? photoQuestId : nil )) } } else { diff --git a/GoInfoGame/GoInfoGame/quests/QuestProtocols.swift b/GoInfoGame/GoInfoGame/quests/QuestProtocols.swift index 3d3180ae..cac32de9 100644 --- a/GoInfoGame/GoInfoGame/quests/QuestProtocols.swift +++ b/GoInfoGame/GoInfoGame/quests/QuestProtocols.swift @@ -124,11 +124,11 @@ class QuestBase { /// queue, which uploads any attached photo and syncs to OSM in the background — /// this always returns immediately, whether or not there's connectivity, so a quest /// (with or without a photo) can be submitted offline. `capturedImage`/`imageTagKey` - /// are non-nil only for a LongForm answer with an attached KartaView photo; every + /// are non-nil only for a LongForm answer with an attached photo; every /// other quest type leaves them at their default (nil), unaffected by this path. - public func updateTags(id: Int64, questType: String, tags:[String:String], type: ElementType, iconName: String, exclude_gig_tags: Bool = false, capturedImage: UIImage? = nil, imageTagKey: String? = nil) { + public func updateTags(id: Int64, questType: String, tags:[String:String], type: ElementType, iconName: String, exclude_gig_tags: Bool = false, capturedImage: UIImage? = nil, imageTagKey: String? = nil, imageQuestion: String? = nil, imageQuestId: Int? = nil) { elementSubmittingToPOSM = type == .way ? .way : .node - QuestSubmissionManager.submit(id: id, questType: questType, tags: tags, type: type, iconName: iconName, capturedImage: capturedImage, imageTagKey: imageTagKey) + QuestSubmissionManager.submit(id: id, questType: questType, tags: tags, type: type, iconName: iconName, capturedImage: capturedImage, imageTagKey: imageTagKey, imageQuestion: imageQuestion, imageQuestId: imageQuestId) } } // Adds default method and implementation diff --git a/GoInfoGame/GoInfoGameTests/APIEndpointAndConfigTests.swift b/GoInfoGame/GoInfoGameTests/APIEndpointAndConfigTests.swift index e11be5c3..a46b534d 100644 --- a/GoInfoGame/GoInfoGameTests/APIEndpointAndConfigTests.swift +++ b/GoInfoGame/GoInfoGameTests/APIEndpointAndConfigTests.swift @@ -77,7 +77,7 @@ final class APIEnvironmentTests: XCTestCase { func testEveryEnvironmentHasWellFormedBaseURLs() { for env in APIEnvironment.allCases { let urls = [env.workspaceBaseURL, env.loginBaseURL, env.osmBaseURL, env.userProfileBaseURL, - env.kartaViewBaseURL, env.kartaViewV2BaseURL] + env.panoramaxBaseURL] for string in urls { let url = URL(string: string) XCTAssertNotNil(url, "\(env) has a malformed base URL: \(string)") @@ -98,8 +98,35 @@ final class APIEnvironmentTests: XCTestCase { XCTAssertTrue(APIEnvironment.development.userProfileBaseURL.contains("-dev")) XCTAssertTrue(APIEnvironment.staging.userProfileBaseURL.contains("-stage")) XCTAssertTrue(APIEnvironment.production.userProfileBaseURL.contains("-prod")) - XCTAssertTrue(APIEnvironment.production.kartaViewV2BaseURL.hasSuffix("/2.0")) - XCTAssertTrue(APIEnvironment.production.kartaViewBaseURL.hasSuffix("/1.0")) + for env in APIEnvironment.allCases { + XCTAssertFalse(env.panoramaxBaseURL.hasSuffix("/"), "\(env) Panoramax URL must not end in /") + XCTAssertFalse(env.panoramaxBaseURL.contains("openstreetmap.fr"), "\(env) must use our own Panoramax instance") + } + } + + func testPanoramaxConfigIsReadForEachEnvironment() { + let info: [String: Any] = [ + "PANORAMAX_BASE_URL_DEV": "https://dev.panoramax.test", "PANORAMAX_TOKEN_DEV": "dev-token", + "PANORAMAX_BASE_URL_STAGE": "https://stage.panoramax.test/", "PANORAMAX_TOKEN_STAGE": " stage-token ", + "PANORAMAX_BASE_URL_PROD": "https://prod.panoramax.test", "PANORAMAX_TOKEN_PROD": "prod-token" + ] + XCTAssertEqual(APIEnvironment.development.panoramaxBaseURL(info: info), "https://dev.panoramax.test") + XCTAssertEqual(APIEnvironment.staging.panoramaxBaseURL(info: info), "https://stage.panoramax.test") + XCTAssertEqual(APIEnvironment.production.panoramaxBaseURL(info: info), "https://prod.panoramax.test") + XCTAssertEqual(APIEnvironment.development.panoramaxToken(info: info), "dev-token") + XCTAssertEqual(APIEnvironment.staging.panoramaxToken(info: info), "stage-token") + XCTAssertEqual(APIEnvironment.production.panoramaxToken(info: info), "prod-token") + } + + func testMissingPanoramaxConfigFallsBackToDevInstanceAndNoToken() { + let fallback = APIEnvironment.development.panoramaxBaseURL(info: [:]) + XCTAssertTrue(fallback.hasPrefix("https://")) + for info: [String: Any] in [[:], ["PANORAMAX_BASE_URL_PROD": "", "PANORAMAX_TOKEN_PROD": ""], + ["PANORAMAX_BASE_URL_PROD": "$(PANORAMAX_BASE_URL_PROD)", + "PANORAMAX_TOKEN_PROD": "$(PANORAMAX_TOKEN_PROD)"]] { + XCTAssertEqual(APIEnvironment.production.panoramaxBaseURL(info: info), fallback) + XCTAssertEqual(APIEnvironment.production.panoramaxToken(info: info), "") + } } } @@ -128,8 +155,6 @@ final class APIConfigurationTests: AppTestCase { XCTAssertEqual(config.loginUrl(for: endpoint)?.absoluteString, APIEnvironment.development.loginBaseURL + "/ping") XCTAssertEqual(config.osmUrl(for: endpoint)?.absoluteString, APIEnvironment.development.osmBaseURL + "/ping") XCTAssertEqual(config.userProfileUrl(for: endpoint)?.absoluteString, APIEnvironment.development.userProfileBaseURL + "/ping") - XCTAssertEqual(config.kartaViewUrl(for: endpoint)?.absoluteString, APIEnvironment.development.kartaViewBaseURL + "/ping") - XCTAssertEqual(config.kartaViewV2Url(for: endpoint)?.absoluteString, APIEnvironment.development.kartaViewV2BaseURL + "/ping") } func testSwitchingEnvironmentChangesSubsequentURLs() { @@ -243,27 +268,6 @@ final class APIEndpointTests: XCTestCase { XCTAssertEqual(e.headers?["Authorization"], "Bearer tok") } - func testKartaviewEndpointsCarryFormDataAndNoHeaders() { - let form: [[String: Any]] = [["key": "k", "value": "v", "type": "text"]] - for (endpoint, path) in [(APIEndpoint.createKartaViewSequence(form), "/sequence/"), - (APIEndpoint.uploadPhotoToKartaview(form), "/photo/"), - (APIEndpoint.finshedUploadingToKartaview(form), "/sequence/finished-uploading/")] { - XCTAssertEqual(endpoint.path, path) - XCTAssertEqual(endpoint.method, "POST") - XCTAssertNil(endpoint.body) - XCTAssertNil(endpoint.headers) - XCTAssertEqual(endpoint.formData?.count, 1) - } - } - - func testFetchPhotoUrlFromKartaviewPercentEncodesTheToken() { - let e = APIEndpoint.fetchPhotoUrlFromKartaview("42", "a b&c") - XCTAssertEqual(e.method, "GET") - XCTAssertTrue(e.path.hasPrefix("/photo/42?access_token=")) - XCTAssertFalse(e.path.contains(" "), "spaces must be percent-encoded: \(e.path)") - XCTAssertNil(e.formData) - } - func testSubmitNoteBuildsJSONBody() { let e = APIEndpoint.submitNote("hello", "tok", 17.5, 78.25, "222") XCTAssertEqual(e.path, "/notes.json") diff --git a/GoInfoGame/GoInfoGameTests/ApiManagerTests.swift b/GoInfoGame/GoInfoGameTests/ApiManagerTests.swift index 4f93ee17..ade6fed5 100644 --- a/GoInfoGame/GoInfoGameTests/ApiManagerTests.swift +++ b/GoInfoGame/GoInfoGameTests/ApiManagerTests.swift @@ -58,9 +58,7 @@ final class ApiManagerTests: AppTestCase { (.workspace, APIEnvironment.staging.workspaceBaseURL), (.login, APIEnvironment.staging.loginBaseURL), (.osm, APIEnvironment.staging.osmBaseURL), - (.userProfile, APIEnvironment.staging.userProfileBaseURL), - (.kartaview, APIEnvironment.staging.kartaViewBaseURL), - (.kartaviewV2, APIEnvironment.staging.kartaViewV2BaseURL) + (.userProfile, APIEnvironment.staging.userProfileBaseURL) ] for (setup, base) in expected { MockHTTP.reset() @@ -121,7 +119,7 @@ final class ApiManagerTests: AppTestCase { ["key": "unsupported", "value": 12345] ] let endpoint = APIEndpoint(path: "/photo/", method: "POST", body: nil, headers: nil, formData: form) - perform(endpoint, setup: .kartaview, as: [String: String].self) + perform(endpoint, setup: .workspace, as: [String: String].self) let sent = try XCTUnwrap(MockHTTP.requests.first) let contentType = try XCTUnwrap(sent.header("Content-Type")) diff --git a/GoInfoGame/GoInfoGameTests/DatabaseConnectorTests.swift b/GoInfoGame/GoInfoGameTests/DatabaseConnectorTests.swift index e85d194d..3569e209 100644 --- a/GoInfoGame/GoInfoGameTests/DatabaseConnectorTests.swift +++ b/GoInfoGame/GoInfoGameTests/DatabaseConnectorTests.swift @@ -358,14 +358,14 @@ final class DatabaseConnectorChangesetTests: AppTestCase { } func testAttachingAnUploadedPhotoURLClearsThePendingPhoto() { - let changeset = makeWayChangeset(pendingImage: ("/tmp/photo.jpg", "ext:kartaview_url")) + let changeset = makeWayChangeset(pendingImage: ("/tmp/photo.jpg", "ext:street_images")) XCTAssertEqual(changeset.pendingImagePath, "/tmp/photo.jpg") - XCTAssertEqual(db.pendingChangesets().first?.pendingImageTagKey, "ext:kartaview_url") + XCTAssertEqual(db.pendingChangesets().first?.pendingImageTagKey, "ext:street_images") - db.attachUploadedPhotoURL(id: changeset.id, tagKey: "ext:kartaview_url", url: "https://cdn/x.jpg") + db.attachUploadedPhotoURL(id: changeset.id, tagKey: "ext:street_images", url: "https://cdn/x.jpg") let stored = db.getChangeset(for: changeset.id) - XCTAssertEqual(stored?.tags["ext:kartaview_url"], "https://cdn/x.jpg") + XCTAssertEqual(stored?.tags["ext:street_images"], "https://cdn/x.jpg") XCTAssertNil(stored?.pendingImagePath) XCTAssertNil(stored?.pendingImageTagKey) } @@ -382,7 +382,7 @@ final class DatabaseConnectorChangesetTests: AppTestCase { } func testAPhotoIsOnlyConsideredStuckAfterThreeFailures() { - let changeset = makeWayChangeset(id: 77, pendingImage: ("/tmp/p.jpg", "ext:kartaview_url")) + let changeset = makeWayChangeset(id: 77, pendingImage: ("/tmp/p.jpg", "ext:street_images")) XCTAssertNil(db.stuckPhotoChangeset(elementId: 77)) db.markChangesetFailed(id: changeset.id, error: "1") db.markChangesetFailed(id: changeset.id, error: "2") @@ -399,7 +399,7 @@ final class DatabaseConnectorChangesetTests: AppTestCase { } func testSkippingThePhotoReturnsItsPathAndLetsTheTagsSync() { - let changeset = makeWayChangeset(pendingImage: ("/tmp/skip.jpg", "ext:kartaview_url")) + let changeset = makeWayChangeset(pendingImage: ("/tmp/skip.jpg", "ext:street_images")) XCTAssertEqual(db.skipPendingPhoto(id: changeset.id), "/tmp/skip.jpg") XCTAssertNil(db.getChangeset(for: changeset.id)?.pendingImagePath) XCTAssertNil(db.skipPendingPhoto(id: changeset.id), "nothing left to skip") @@ -448,6 +448,23 @@ final class DatabaseConnectorDraftTests: AppTestCase { db.markNoteDraftFailed(id: "missing", error: "ignored") } + func testUploadedPhotoURLsAreKeptOnTheDraftAndClearedByAResubmit() { + db.upsertNoteDraft(id: "a", noteText: "x", imagePaths: ["/p/1.jpg", "/p/2.jpg"], coordinates: here) + db.attachNoteDraftPhotoURL(id: "a", photoIndex: 0, url: "https://pmx.test/1") + db.attachNoteDraftPhotoURL(id: "missing", photoIndex: 0, url: "ignored") + XCTAssertEqual(db.pendingNoteDrafts().first?.uploadedPhotoURLs, [0: "https://pmx.test/1"]) + + db.upsertFeatureDraft(id: "f", presetName: "Bench", iconName: "i", tags: [:], imagePaths: ["/f/1.jpg"], coordinates: here) + db.attachFeatureDraftPhotoURL(id: "f", photoIndex: 0, url: "https://pmx.test/f1") + XCTAssertEqual(db.pendingFeatureDrafts().first?.uploadedPhotoURLs, [0: "https://pmx.test/f1"]) + + // Resubmitting a draft replaces its photos, so the old URLs no longer apply. + db.upsertNoteDraft(id: "a", noteText: "y", imagePaths: ["/p/3.jpg"], coordinates: here) + db.upsertFeatureDraft(id: "f", presetName: "Bench", iconName: "i", tags: [:], imagePaths: [], coordinates: here) + XCTAssertEqual(db.pendingNoteDrafts().first?.uploadedPhotoURLs, [:]) + XCTAssertEqual(db.pendingFeatureDrafts().first?.uploadedPhotoURLs, [:]) + } + func testDeletingANoteDraft() { db.upsertNoteDraft(id: "a", noteText: "x", imagePaths: [], coordinates: here) db.deleteNoteDraft(id: "a") diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookup.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookup.json deleted file mode 100644 index 67d447aa..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookup.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "result": { - "data": { - "imageLthUrl": "https://cdn.kartaview.org/pr:sharp/unit-test-photo-777001.jpg" - } - } -} diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookupEmpty.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookupEmpty.json deleted file mode 100644 index 9b29666a..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoLookupEmpty.json +++ /dev/null @@ -1 +0,0 @@ -{ "result": { "data": {} } } diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoUploaded.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoUploaded.json deleted file mode 100644 index 38f24e11..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewPhotoUploaded.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "status": { "apiCode": 600, "apiMessage": "Success", "httpCode": 200, "httpMessage": "Success" }, - "osv": { - "photo": { - "id": "777001", - "sequenceId": "9001", - "dateAdded": "2026-10-15 09:00:05", - "sequenceIndex": "1", - "photoName": "osmlogo.jpeg", - "lat": "17.4385", - "lng": "78.361", - "path": "files/photo/2026/10/15/777001.jpg" - } - } -} diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewSequenceCreated.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewSequenceCreated.json deleted file mode 100644 index 676f7c24..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewSequenceCreated.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "status": { "apiCode": 600, "apiMessage": "Success", "httpCode": 200, "httpMessage": "Success" }, - "osv": { - "sequence": { - "id": "9001", - "userId": "42", - "dateAdded": "2026-10-15 09:00:00", - "currentLat": "17.4385", - "currentLng": "78.361", - "status": "active", - "imagesStatus": "new", - "metaDataFilename": "", - "processingStatus": "NEW", - "countActivePhotos": "0" - } - } -} diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewStatusFailure.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewStatusFailure.json deleted file mode 100644 index 20912979..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewStatusFailure.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": { "apiCode": 660, "apiMessage": "Unauthorized", "httpCode": 401, "httpMessage": "Unauthorized" }, - "osv": { "sequenceId": "0" } -} diff --git a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewUploadFinished.json b/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewUploadFinished.json deleted file mode 100644 index 25c7d771..00000000 --- a/GoInfoGame/GoInfoGameTests/Fixtures/KartaviewUploadFinished.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": { "apiCode": 600, "apiMessage": "Success", "httpCode": 200, "httpMessage": "Success" }, - "osv": { "sequenceId": "9001" } -} diff --git a/GoInfoGame/GoInfoGameTests/SubmissionManagerTests.swift b/GoInfoGame/GoInfoGameTests/SubmissionManagerTests.swift index 3ad548bf..cb68e410 100644 --- a/GoInfoGame/GoInfoGameTests/SubmissionManagerTests.swift +++ b/GoInfoGame/GoInfoGameTests/SubmissionManagerTests.swift @@ -3,7 +3,7 @@ // GoInfoGameTests // // The three offline-first submission queues - quest answers (QuestSubmissionManager), notes -// (NotesSubmissionManager) and Add Feature (FeatureSubmissionManager) - plus Kartaview photo +// (NotesSubmissionManager) and Add Feature (FeatureSubmissionManager) - plus Panoramax photo // uploads and the on-disk image stores. Each queue persists first, then drains in the // background and reports through MapViewPublisher.dismissSheet, so these tests drive a // submission and wait on those same events. Elements and server responses come from the UI @@ -17,132 +17,145 @@ import osmapi import osmparser @testable import GoInfoGame -// MARK: - Kartaview photo upload - -final class KartaviewViewModelTests: AppTestCase { - - private func upload(_ model: KartaviewViewModel, timeout: TimeInterval = 8) -> (String, Bool) { - let exp = expectation(description: "upload") - var outcome = ("", false) - model.createSequence { message, ok in outcome = (message, ok); exp.fulfill() } - wait(for: [exp], timeout: timeout) - return outcome +// MARK: - Panoramax photo upload + +final class PanoramaxUploaderTests: AppTestCase { + + private let spot = CLLocationCoordinate2D(latitude: 47.6553, longitude: -122.3035) + // ASCII stand-in for JPEG bytes, so the recorded multipart body stays readable as text. + private let photo = Data("jpeg-bytes".utf8) + + private var uploader: PanoramaxUploader { PhotoUploadService.makePanoramaxUploader() } + + func testAnUploadReturnsTheHostedPhotoURL() async throws { + MockServer.installPanoramax(.ok) + let url = try await uploader.upload(imageData: photo, coordinate: spot) + XCTAssertEqual(url, MockServer.panoramaxPhotoURL) + } + + func testTheUploadSetHoldsOnePhotoThatIsNeverSplitOrDeduplicated() async throws { + MockServer.installPanoramax(.ok) + _ = try await uploader.upload(imageData: photo, coordinate: spot) + + let create = try XCTUnwrap(MockHTTP.requests(path: "/api/upload_sets", method: "POST").first) + XCTAssertEqual(create.header("Authorization"), "Bearer \(MockServer.panoramaxToken)") + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: try XCTUnwrap(create.body)) as? [String: Any]) + XCTAssertEqual(json["estimated_nb_files"] as? Int, 1) + XCTAssertEqual(json["no_split"] as? Bool, true) + XCTAssertEqual(json["no_deduplication"] as? Bool, true) + XCTAssertEqual(json["visibility"] as? String, "anyone") + } + + func testThePhotoIsSentWithItsLocationAndCaptureTime() async throws { + MockServer.installPanoramax(.ok) + let capturedAt = Date(timeIntervalSince1970: 1_793_000_000) + _ = try await uploader.upload(imageData: photo, coordinate: spot, capturedAt: capturedAt) + + let file = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").first) + XCTAssertEqual(file.header("Authorization"), "Bearer \(MockServer.panoramaxToken)") + let body = file.bodyString + XCTAssertTrue(body.contains("name=\"override_latitude\"\r\n\r\n47.6553\r\n")) + XCTAssertTrue(body.contains("name=\"override_longitude\"\r\n\r\n-122.3035\r\n")) + XCTAssertTrue(body.contains("name=\"override_capture_time\"\r\n\r\n\(ISO8601DateFormatter().string(from: capturedAt))\r\n")) + XCTAssertTrue(body.contains("Content-Type: image/jpeg\r\n\r\njpeg-bytes\r\n")) + XCTAssertFalse(body.contains("name=\"semantics\""), "no semantics field without context") + } + + func testThePhotoCarriesItsContextAsSemantics() async throws { + MockServer.installPanoramax(.ok) + let context = PhotoUploadContext(workspaceId: "222", elementId: "way/121949", question: "What is the surface?", + questId: 7) + _ = try await uploader.upload(imageData: photo, coordinate: spot, semantics: context.semantics) + + let file = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").first) + let body = file.bodyString + let marker = "name=\"semantics\"\r\n\r\n" + let start = try XCTUnwrap(body.range(of: marker)).upperBound + // "\r\n" is one Character in Swift, so find the end by range, not by Character. + let end = try XCTUnwrap(body.range(of: "\r\n", range: start.. upload photo -> finish -> look the photo up by its own id") - } - - func testThePhotoIsSentAsMultipartWithTheSequenceAndCoordinates() throws { - MockServer.installKartaview(.ok) - let model = KartaviewViewModel(capturedImage: makeImage()) - _ = upload(model) - - let photoRequest = try XCTUnwrap(MockHTTP.requests(path: "/1.0/photo/").first) - let body = try XCTUnwrap(photoRequest.body) - let text = String(decoding: body, as: UTF8.self) - XCTAssertTrue(photoRequest.header("Content-Type")?.hasPrefix("multipart/form-data; boundary=") == true) - XCTAssertTrue(text.contains("name=\"sequenceId\"\r\n\r\n9001")) - XCTAssertTrue(text.contains("name=\"sequenceIndex\"\r\n\r\n1")) - XCTAssertTrue(text.contains("name=\"coordinate\"\r\n\r\n0.0, 0.0"), "no GPS fix in a test -> the 0.0 default") - XCTAssertTrue(text.contains("name=\"photo\"; filename=\"osmlogo.jpeg\"")) - XCTAssertTrue(text.contains("name=\"headers\"\r\n\r\n0")) - XCTAssertNotNil(body.range(of: Data([0xFF, 0xD8])), "the JPEG bytes are embedded") - } - - func testEachStepsFailureIsReportedWithItsOwnMessage() { - let cases: [(MockServer.Kartaview, String)] = [ - (.createStatusNot200, "Failed to create Kartaview sequence"), - (.uploadStatusNot200, "Failed to upload photo to Kartaview"), - (.finishStatusNot200, "Failed to finish Kartaview upload"), - (.finishFails, "An error occured") - ] - for (mode, expected) in cases { - MockHTTP.reset() - MockServer.installKartaview(mode) - let (message, ok) = upload(KartaviewViewModel(capturedImage: makeImage())) - XCTAssertFalse(ok, "\(mode)") - XCTAssertEqual(message, expected, "\(mode)") + func testARejectedPhotoThrowsTheReasonEvenThoughTheStatusIs202() async { + MockServer.installPanoramax(.rejected) + do { + _ = try await uploader.upload(imageData: photo, coordinate: spot) + XCTFail("a rejected photo must not return a URL") + } catch { + XCTAssertEqual(error as? PanoramaxError, .rejected("No GPS coordinates")) } } - func testTransportFailuresCarryTheErrorDescription() { - MockServer.installKartaview(.createFails) - var (message, ok) = upload(KartaviewViewModel(capturedImage: makeImage())) - XCTAssertFalse(ok) - XCTAssertEqual(message, "Internal server error. Please try again later.") - - MockHTTP.reset() - MockServer.installKartaview(.uploadFails) - (message, ok) = upload(KartaviewViewModel(capturedImage: makeImage())) - XCTAssertFalse(ok) - XCTAssertEqual(message, "Internal server error. Please try again later.") - } - - func testAMissingHostedURLIsAGenuineFailureAfterOneRetry() { - MockServer.installKartaview(.lookupWithoutURL) - let (message, ok) = upload(KartaviewViewModel(capturedImage: makeImage()), timeout: 12) - XCTAssertFalse(ok) - XCTAssertEqual(message, "Failed to retrieve photo URL") - XCTAssertEqual(MockHTTP.requests(pathRegex: #"^/2\.0/photo/"#).count, 2, "one initial lookup plus one retry after a pause") - } - - func testAnImageThatCannotBeEncodedIsRejectedBeforeAnyRequest() { - let model = KartaviewViewModel(capturedImage: UIImage()) - XCTAssertNil(model.imageToData(image: UIImage())) - let exp = expectation(description: "upload") - var outcome = ("", true) - model.uploadPhoto(sequenceId: "1") { outcome = ($0, $1); exp.fulfill() } - wait(for: [exp], timeout: 2) - XCTAssertEqual(outcome.0, "Failed to process the captured photo") - XCTAssertFalse(outcome.1) - XCTAssertTrue(MockHTTP.requests.isEmpty) + func testAnInvalidTokenThrowsTheServerMessageAndUploadsNothing() async { + MockServer.installPanoramax(.unauthorized) + do { + _ = try await uploader.upload(imageData: photo, coordinate: spot) + XCTFail("an unauthorized upload must fail") + } catch { + XCTAssertEqual(error as? PanoramaxError, .server(status: 401, message: "Token not valid")) + } + XCTAssertTrue(MockHTTP.requests(path: MockServer.panoramaxFilesPath).isEmpty) } - func testTheAsyncWrapperReturnsTheURLOrThrows() async throws { - MockServer.installKartaview(.ok) - let url = try await KartaviewViewModel(capturedImage: makeImage()).uploadAsync() - XCTAssertEqual(url, MockServer.kartaviewPhotoURL) - - MockHTTP.reset() - MockServer.installKartaview(.createStatusNot200) + func testAMissingTokenFailsWithoutAnyRequest() async { + MockServer.installPanoramax(.ok) do { - _ = try await KartaviewViewModel(capturedImage: makeImage()).uploadAsync() - XCTFail("expected an error") + _ = try await PanoramaxUploader(baseURL: MockServer.panoramaxBaseURL, token: "").upload(imageData: photo, coordinate: spot) + XCTFail("an upload without a token must fail") } catch { - XCTAssertEqual((error as NSError).localizedDescription, "Failed to create Kartaview sequence") + XCTAssertEqual(error as? PanoramaxError, .missingToken) } + XCTAssertTrue(MockHTTP.requests.isEmpty) } - func testTheKartaviewModelsDecodeTheirFixtures() throws { - let decoder = JSONDecoder() - let sequence = try decoder.decode(SequenceModel.self, from: MockHTTP.fixture("KartaviewSequenceCreated")) - XCTAssertEqual(sequence.osv.sequence.id, "9001") - XCTAssertEqual(sequence.status.httpCode, 200) - let photo = try decoder.decode(UploadPhotoModel.self, from: MockHTTP.fixture("KartaviewPhotoUploaded")) - XCTAssertEqual(photo.osv.photo.sequenceID, "9001") - let finished = try decoder.decode(FinishUploadingModel.self, from: MockHTTP.fixture("KartaviewUploadFinished")) - XCTAssertEqual(finished.osv.sequenceID, "9001") - let lookup = try decoder.decode(PhotoLookupResponse.self, from: MockHTTP.fixture("KartaviewPhotoLookup")) - XCTAssertEqual(lookup.result?.data?.imageLthUrl, MockServer.kartaviewPhotoURL) - XCTAssertNil(try decoder.decode(PhotoLookupResponse.self, from: MockHTTP.fixture("KartaviewPhotoLookupEmpty")).result?.data?.imageLthUrl) - } + func testTheServiceSendsPhotosToPanoramax() async throws { + MockServer.installPanoramax(.ok) + let url = try await PhotoUploadService.upload(makeImage(), coordinate: spot) - func testJSONNullDecodesOnlyFromNull() throws { - XCTAssertNoThrow(try JSONDecoder().decode([JSONNull?].self, from: Data("[null]".utf8))) - XCTAssertThrowsError(try JSONDecoder().decode(JSONNull.self, from: Data("1".utf8))) - XCTAssertEqual(JSONNull(), JSONNull()) - XCTAssertEqual(JSONNull().hashValue, 0) - XCTAssertEqual(String(decoding: try JSONEncoder().encode([JSONNull()]), as: UTF8.self), "[null]") + XCTAssertEqual(url, MockServer.panoramaxPhotoURL) + let file = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first) + XCTAssertTrue(file.header("Content-Type")?.hasPrefix("multipart/form-data; boundary=") == true) } } @@ -213,9 +226,10 @@ final class QuestSubmissionManagerTests: AppTestCase { events = recordSheetEvents() } - private func submitSidewalk(tags: [String: String] = ["ext:surface": "concrete"], image: UIImage? = nil, imageTagKey: String? = nil) { + private func submitSidewalk(tags: [String: String] = ["ext:surface": "concrete"], image: UIImage? = nil, imageTagKey: String? = nil, + imageQuestion: String? = nil, imageQuestId: Int? = nil) { QuestSubmissionManager.submit(id: 121949, questType: "Sidewalks", tags: tags, type: .way, iconName: "sidewalk_surface", - capturedImage: image, imageTagKey: imageTagKey) + capturedImage: image, imageTagKey: imageTagKey, imageQuestion: imageQuestion, imageQuestId: imageQuestId) } private func waitForDrain(_ message: String = "queue never finished draining") { @@ -319,20 +333,20 @@ final class QuestSubmissionManagerTests: AppTestCase { // MARK: Photos func testAnAttachedPhotoIsUploadedFirstAndItsURLBecomesATag() throws { - MockServer.installKartaview(.ok) - submitSidewalk(image: makeImage(), imageTagKey: "ext:kartaview_url") + MockServer.installPanoramax(.ok) + submitSidewalk(image: makeImage(), imageTagKey: PhotoTag.streetImages) waitUntil(timeout: 15, "the answer never synced") { events.contains("answerSynced") } let synced = try XCTUnwrap(db.getChangesets(synced: true).first) - XCTAssertEqual(synced.tags["ext:kartaview_url"], MockServer.kartaviewPhotoURL) + XCTAssertEqual(synced.tags[PhotoTag.streetImages], MockServer.panoramaxPhotoURL) XCTAssertNil(synced.pendingImagePath) let upload = try XCTUnwrap(MockHTTP.requests(pathRegex: MockServer.changesetUploadRegex).first) - XCTAssertTrue(upload.bodyString.contains(MockServer.kartaviewPhotoURL), "the photo URL is part of the uploaded tags") + XCTAssertTrue(upload.bodyString.contains(MockServer.panoramaxPhotoURL), "the photo URL is part of the uploaded tags") } func testAPhotoThatFailsToUploadKeepsTheAnswerQueuedAndWarnsTheUser() throws { - MockServer.installKartaview(.createStatusNot200) - submitSidewalk(image: makeImage(), imageTagKey: "ext:kartaview_url") + MockServer.installPanoramax(.unauthorized) + submitSidewalk(image: makeImage(), imageTagKey: PhotoTag.streetImages) waitForDrain() let pending = try XCTUnwrap(db.getChangesets().first) @@ -343,25 +357,59 @@ final class QuestSubmissionManagerTests: AppTestCase { return XCTFail("the user was never told about the failed photo") } XCTAssertEqual(name, "Sidewalks") - XCTAssertEqual(message, "Failed to create Kartaview sequence") + XCTAssertEqual(message, "Panoramax error 401: Token not valid") + } + + func testAPhotoQueuedUnderTheKartaviewTagByAnOlderVersionGoesToStreetImages() throws { + MockServer.installPanoramax(.ok) + submitSidewalk(image: makeImage(), imageTagKey: PhotoTag.kartaview) + waitUntil(timeout: 15, "the answer never synced") { events.contains("answerSynced") } + + let synced = try XCTUnwrap(db.getChangesets(synced: true).first) + XCTAssertEqual(synced.tags[PhotoTag.streetImages], MockServer.panoramaxPhotoURL) + XCTAssertNil(synced.tags[PhotoTag.kartaview]) + } + + func testThePhotoCarriesItsQuestContextAndIsPlacedOnTheWay() throws { + MockServer.installPanoramax(.ok) + submitSidewalk(image: makeImage(), imageTagKey: PhotoTag.streetImages, imageQuestion: "What is the surface?", + imageQuestId: 7) + waitUntil(timeout: 15, "the answer never synced") { events.contains("answerSynced") } + + let synced = try XCTUnwrap(db.getChangesets(synced: true).first) + XCTAssertEqual(synced.tags[PhotoTag.streetImages], MockServer.panoramaxPhotoURL) + XCTAssertNil(synced.tags[PhotoTag.kartaview]) + let semantics = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first).bodyString + for expected in [#""key":"workspace_id","value":"222""#, #""key":"element_id","value":"way/121949""#, #""key":"quest_id","value":"7""#, + #""key":"question","value":"What is the surface?""#, #""key":"source","value":"quest""#] { + XCTAssertTrue(semantics.contains(expected), "semantics missing \(expected)") + } + XCTAssertFalse(semantics.contains("user_email"), "the user's email is never sent") + + // A way's photo is placed at its first node. + let firstNodeId = try XCTUnwrap(db.getWay(id: 121949)?.nodes.first) + let firstNode = try XCTUnwrap(db.getNode(id: Int(firstNodeId))) + let file = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first) + XCTAssertTrue(file.bodyString.contains("name=\"override_latitude\"\r\n\r\n\(firstNode.latitude)\r\n")) + XCTAssertTrue(file.bodyString.contains("name=\"override_longitude\"\r\n\r\n\(firstNode.longitude)\r\n")) } func testAPhotoFileThatWentMissingIsRecordedAsAFailure() throws { - MockServer.installKartaview(.ok) + MockServer.installPanoramax(.ok) let id = db.createChangeset(id: 121949, questType: "Sidewalks", type: .way, originalTags: ["footway": "sidewalk", "highway": "footway"], tags: ["ext:surface": "concrete"], version: 1, iconName: "i", - pendingImagePath: "/nonexistent/photo.jpg", pendingImageTagKey: "ext:kartaview_url")!.id + pendingImagePath: "/nonexistent/photo.jpg", pendingImageTagKey: PhotoTag.streetImages)!.id QuestSubmissionManager.resumePendingUploads() waitForDrain() XCTAssertEqual(db.getChangeset(for: id)?.lastError, "Saved photo could not be read") - XCTAssertTrue(MockHTTP.requests(path: "/1.0/sequence/").isEmpty) + XCTAssertTrue(MockHTTP.requests(path: "/api/upload_sets").isEmpty) } func testSkippingAStuckPhotoSyncsJustTheTagsAndRemovesTheFile() throws { let path = try QuestImageStorage.shared.save(makeImage()) let id = db.createChangeset(id: 121949, questType: "Sidewalks", type: .way, originalTags: ["footway": "sidewalk", "highway": "footway"], tags: ["ext:surface": "concrete"], version: 1, iconName: "i", - pendingImagePath: path, pendingImageTagKey: "ext:kartaview_url")!.id + pendingImagePath: path, pendingImageTagKey: PhotoTag.streetImages)!.id QuestSubmissionManager.skipPendingPhoto(changesetId: id) waitUntil(timeout: 10, "the tags never synced after skipping the photo") { events.contains("answerSynced") } @@ -370,7 +418,7 @@ final class QuestSubmissionManagerTests: AppTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: path), "the abandoned photo file is deleted") XCTAssertEqual(db.getChangeset(for: id)?.updatedVersion, 2) - XCTAssertTrue(MockHTTP.requests(path: "/1.0/sequence/").isEmpty, "no upload is attempted") + XCTAssertTrue(MockHTTP.requests(path: "/api/upload_sets").isEmpty, "no upload is attempted") } } @@ -408,14 +456,14 @@ final class NotesSubmissionManagerTests: AppTestCase { func testAPhotoIsUploadedAndItsLinkAppendedToTheNote() throws { MockServer.installNotes() - MockServer.installKartaview(.ok) + MockServer.installPanoramax(.ok) NotesSubmissionManager.submit(NoteDraft(noteText: "See photo", images: [makeImage()], coordinates: here)) waitForDrain() XCTAssertTrue(events.contains("noteSubmitted")) let sent = try XCTUnwrap(MockHTTP.requests(path: "\(MockServer.osm)/notes.json").first) let text = (try JSONSerialization.jsonObject(with: try XCTUnwrap(sent.body)) as? [String: Any])?["text"] as? String - XCTAssertEqual(text, "See photo\n\n\(MockServer.kartaviewPhotoURL)") + XCTAssertEqual(text, "See photo\n\n\(MockServer.panoramaxPhotoURL)") } func testANoteSubmittedOfflineStaysQueuedUntilTheNextAttempt() { @@ -434,7 +482,7 @@ final class NotesSubmissionManagerTests: AppTestCase { func testAPhotoFailureKeepsTheNoteQueuedWithAReadableError() throws { MockServer.installNotes() - MockServer.installKartaview(.createStatusNot200) + MockServer.installPanoramax(.unauthorized) NotesSubmissionManager.submit(NoteDraft(noteText: "photo fails", images: [makeImage()], coordinates: here)) waitForDrain() @@ -442,7 +490,21 @@ final class NotesSubmissionManagerTests: AppTestCase { XCTAssertTrue(MockHTTP.requests(path: "\(MockServer.osm)/notes.json").isEmpty, "the note is not sent without its photo") let realm = try Realm(configuration: RealmConfig.configuration) let draft = try XCTUnwrap(realm.objects(StoredNoteDraft.self).first) - XCTAssertEqual(draft.lastError, "Failed to upload photo 1 to Kartaview: Failed to create Kartaview sequence") + XCTAssertEqual(draft.lastError, "Failed to upload photo 1 to Panoramax: Panoramax error 401: Token not valid") + NoteImageStorage.shared.delete(paths: Array(draft.imagePaths)) + } + + func testARejectedPhotoKeepsTheNoteQueued() throws { + MockServer.installNotes() + MockServer.installPanoramax(.rejected) + NotesSubmissionManager.submit(NoteDraft(noteText: "photo rejected", images: [makeImage()], coordinates: here)) + waitForDrain() + + XCTAssertEqual(db.pendingNoteDraftsCount(), 1) + XCTAssertTrue(MockHTTP.requests(path: "\(MockServer.osm)/notes.json").isEmpty, "the note is not sent without its photo") + let realm = try Realm(configuration: RealmConfig.configuration) + let draft = try XCTUnwrap(realm.objects(StoredNoteDraft.self).first) + XCTAssertEqual(draft.lastError, "Failed to upload photo 1 to Panoramax: Panoramax rejected the photo: No GPS coordinates") NoteImageStorage.shared.delete(paths: Array(draft.imagePaths)) } @@ -463,6 +525,77 @@ final class NotesSubmissionManagerTests: AppTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: firstPaths[0]), "the superseded photo is deleted") } + func testAPhotoAlreadyUploadedIsNotUploadedAgainWhenTheNoteIsRetried() throws { + MockServer.installNotes(success: false) + MockServer.installPanoramax(.ok) + let draft = NoteDraft(noteText: "retry me", images: [makeImage()], coordinates: here) + NotesSubmissionManager.submit(draft) + waitUntil(timeout: 12, "the uploaded photo's URL was never saved on the draft") { + db.pendingNoteDrafts().first { $0.id == draft.id }?.uploadedPhotoURLs == [0: MockServer.panoramaxPhotoURL] + } + waitForDrain() + XCTAssertEqual(MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").count, 1) + + MockHTTP.reset() + MockServer.installNotes() + MockServer.installPanoramax(.ok) + NotesSubmissionManager.resumePendingUploads() + waitUntil(timeout: 10, "the queued note was never retried") { events.contains("noteSubmitted") } + + XCTAssertTrue(MockHTTP.requests(path: "/api/upload_sets", method: "POST").isEmpty, "the photo is not uploaded twice") + let sent = try XCTUnwrap(MockHTTP.requests(path: "\(MockServer.osm)/notes.json").first) + let text = (try JSONSerialization.jsonObject(with: try XCTUnwrap(sent.body)) as? [String: Any])?["text"] as? String + XCTAssertEqual(text, "retry me\n\n\(MockServer.panoramaxPhotoURL)") + } + + func testWhenTheSecondPhotoFailsOnlyThatOneIsUploadedOnRetry() throws { + MockServer.installNotes() + MockServer.installPanoramax(.ok) + let uploads = Counter() + MockHTTP.stub("POST", path: MockServer.panoramaxFilesPath) { _ in + uploads.increment() == 1 + ? .json(#"{"file_name":"a.jpg","picture_id":"\#(MockServer.panoramaxPictureID)","rejected":null}"#, status: 202) + : .offline + } + let draft = NoteDraft(noteText: "two photos", images: [makeImage(), makeImage(.blue)], coordinates: here) + NotesSubmissionManager.submit(draft) + waitForDrain() + waitUntil(timeout: 12, "the first photo's URL was never saved on the draft") { + (try? Realm(configuration: RealmConfig.configuration))? + .object(ofType: StoredNoteDraft.self, forPrimaryKey: draft.id)?.lastError != nil + } + XCTAssertEqual(db.pendingNoteDrafts().first { $0.id == draft.id }?.uploadedPhotoURLs.count, 1, "the first photo's URL is kept") + + MockHTTP.reset() + MockServer.installNotes() + MockServer.installPanoramax(.ok) + NotesSubmissionManager.resumePendingUploads() + waitUntil(timeout: 10, "the queued note was never retried") { events.contains("noteSubmitted") } + XCTAssertEqual(MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").count, 1, + "only the photo that failed is uploaded again") + } + + func testAQueuedNotePhotoKeepsItsCaptureTimeAndIsLabelledAsANote() throws { + MockServer.installNotes() + MockServer.installPanoramax(.offline) + NotesSubmissionManager.submit(NoteDraft(noteText: "offline", images: [makeImage()], coordinates: here)) + waitForDrain() + let realm = try Realm(configuration: RealmConfig.configuration) + let draft = try XCTUnwrap(realm.objects(StoredNoteDraft.self).first) + try realm.write { draft.createdAt = Date(timeIntervalSince1970: 1_767_323_045) } // 2026-01-02T03:04:05Z + + MockHTTP.reset() + MockServer.installNotes() + MockServer.installPanoramax(.ok) + NotesSubmissionManager.resumePendingUploads() + waitUntil(timeout: 10, "the queued note was never retried") { events.contains("noteSubmitted") } + + let body = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first).bodyString + XCTAssertTrue(body.contains("name=\"override_capture_time\"\r\n\r\n2026-01-02T03:04:05Z\r\n")) + XCTAssertTrue(body.contains(#""key":"source","value":"note""#)) + XCTAssertTrue(body.contains(#""key":"workspace_id","value":"222""#)) + } + func testResumingWithNothingQueuedIsSilent() { NotesSubmissionManager.resumePendingUploads() RunLoop.main.run(until: Date().addingTimeInterval(0.3)) @@ -574,11 +707,54 @@ final class FeatureSubmissionManagerTests: AppTestCase { } func testPhotosAreUploadedAndNumberedIntoTheTags() throws { - MockServer.installKartaview(.ok) + MockServer.installPanoramax(.ok) FeatureSubmissionManager.submit(bench(image: makeImage())) waitForDrain() - XCTAssertEqual(db.getNode(id: 555)?.tags["ext:image_url1"], MockServer.kartaviewPhotoURL) + XCTAssertEqual(db.getNode(id: 555)?.tags["ext:image_url1"], MockServer.panoramaxPhotoURL) XCTAssertEqual(db.pendingFeatureDraftsCount(), 0) + let body = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first).bodyString + XCTAssertTrue(body.contains(#""key":"source","value":"new_feature""#)) + XCTAssertTrue(body.contains(#""key":"feature_type","value":"Bench""#)) + } + + func testOnceTheNodeIsCreatedItsIdIsAddedToThePhotos() throws { + MockServer.installPanoramax(.ok) + FeatureSubmissionManager.submit(bench(image: makeImage())) + waitForDrain() + waitUntil(timeout: 10, "the photo was never tagged with the node") { + !MockHTTP.requests(path: MockServer.panoramaxPicturePath, method: "PATCH").isEmpty + } + let patch = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxPicturePath, method: "PATCH").first) + XCTAssertEqual(patch.header("Authorization"), "Bearer \(MockServer.panoramaxToken)") + XCTAssertEqual(patch.bodyString, #"{"semantics":[{"action":"add","key":"element_id","value":"node/555"}]}"#) + } + + func testAFeatureWithoutPhotosTagsNothing() { + MockServer.installPanoramax(.ok) + FeatureSubmissionManager.submit(bench()) + waitForDrain() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + XCTAssertTrue(MockHTTP.requests(path: MockServer.panoramaxPicturePath, method: "PATCH").isEmpty) + } + + func testAPhotoAlreadyUploadedIsNotUploadedAgainWhenTheFeatureIsRetried() throws { + MockHTTP.reset() + MockServer.installChangesets(createFails: true) + MockServer.installPanoramax(.ok) + FeatureSubmissionManager.submit(bench(image: makeImage())) + waitForDrain() + XCTAssertEqual(db.pendingFeatureDraftsCount(), 1) + XCTAssertEqual(MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").count, 1) + + MockHTTP.reset() + MockServer.installChangesets(changesetID: 900, upload: .nodeCreated(id: 555, version: 1)) + MockServer.installPanoramax(.ok) + events = recordSheetEvents() + FeatureSubmissionManager.resumePendingUploads() + waitUntil(timeout: 10, "the queued feature was never retried") { events.contains("featureSubmitted") } + + XCTAssertTrue(MockHTTP.requests(path: "/api/upload_sets", method: "POST").isEmpty, "the photo is not uploaded twice") + XCTAssertEqual(db.getNode(id: 555)?.tags["ext:image_url1"], MockServer.panoramaxPhotoURL) } func testAFeatureCreatedOfflineStaysQueuedAndIsRetriedLater() { @@ -600,14 +776,14 @@ final class FeatureSubmissionManagerTests: AppTestCase { } func testAPhotoFailureKeepsTheFeatureQueuedForRetry() throws { - MockServer.installKartaview(.createStatusNot200) + MockServer.installPanoramax(.unauthorized) FeatureSubmissionManager.submit(bench(image: makeImage())) waitForDrain() XCTAssertEqual(db.pendingFeatureDraftsCount(), 1) XCTAssertTrue(MockHTTP.requests(pathRegex: MockServer.changesetUploadRegex).isEmpty) let realm = try Realm(configuration: RealmConfig.configuration) let draft = try XCTUnwrap(realm.objects(StoredFeatureDraft.self).first) - XCTAssertEqual(draft.lastError, "Failed to upload photo 1 to Kartaview: Failed to create Kartaview sequence") + XCTAssertEqual(draft.lastError, "Failed to upload photo 1 to Panoramax: Panoramax error 401: Token not valid") FeatureImageStorage.shared.delete(paths: Array(draft.imagePaths)) } @@ -618,3 +794,183 @@ final class FeatureSubmissionManagerTests: AppTestCase { XCTAssertTrue(events.names.isEmpty) } } + +final class PanoramaxSemanticsUpdateTests: AppTestCase { + + private var uploader: PanoramaxUploader { PhotoUploadService.makePanoramaxUploader() } + private var patches: [MockHTTP.Recorded] { MockHTTP.requests(path: MockServer.panoramaxPicturePath, method: "PATCH") } + + func testThePictureIdIsReadOnlyFromThisInstancesPictureURLs() { + let id = MockServer.panoramaxPictureID + XCTAssertEqual(uploader.pictureId(fromURL: MockServer.panoramaxPhotoURL), id) + XCTAssertEqual(uploader.pictureId(fromURL: "\(MockServer.panoramaxBaseURL)/api/pictures/\(id)/thumb.jpg"), id) + XCTAssertNil(uploader.pictureId(fromURL: "https://other.panoramax.test/api/pictures/\(id)/hd.jpg")) + XCTAssertNil(uploader.pictureId(fromURL: "https://kartaview.org/details/1/2")) + XCTAssertNil(uploader.pictureId(fromURL: "\(MockServer.panoramaxBaseURL)/api/pictures//hd.jpg")) + } + + func testTaggingRetriesWhileThePictureIsNotDispatchedYet() async { + let calls = Counter() + MockHTTP.stub("PATCH", path: MockServer.panoramaxPicturePath) { _ in + calls.increment() < 3 ? .statusWithBody(404, #"{"message":"Picture not found","status_code":404}"#) + : .json(#"{"id":"x"}"#, status: 200) + } + let tagged = await PhotoUploadService.tagPhotos([MockServer.panoramaxPhotoURL], withElementId: "node/7") + XCTAssertEqual(tagged, [MockServer.panoramaxPhotoURL]) + XCTAssertEqual(patches.count, 3) + } + + func testTaggingGivesUpAtOnceWhenNotAllowed() async { + MockHTTP.stub("PATCH", path: MockServer.panoramaxPicturePath, + reply: .statusWithBody(403, #"{"message":"Forbidden","status_code":403}"#)) + let tagged = await PhotoUploadService.tagPhotos([MockServer.panoramaxPhotoURL], withElementId: "node/7") + XCTAssertTrue(tagged.isEmpty) + XCTAssertEqual(patches.count, 1) + } + + func testAlreadyTaggedCountsAsDone() async { + MockHTTP.stub("PATCH", path: MockServer.panoramaxPicturePath, reply: .statusWithBody(304, "")) + let tagged = await PhotoUploadService.tagPhotos([MockServer.panoramaxPhotoURL], withElementId: "node/7") + XCTAssertEqual(tagged.count, 1) + XCTAssertEqual(patches.count, 1) + } + + func testPhotosFromElsewhereAreSkipped() async { + let tagged = await PhotoUploadService.tagPhotos(["https://kartaview.org/p/1.jpg"], withElementId: "node/7") + XCTAssertTrue(tagged.isEmpty) + XCTAssertTrue(patches.isEmpty) + } +} + +final class PhotoTagTests: XCTestCase { + + func testStreetImagesWinsOverTheOlderKartaviewTag() { + let tags = [PhotoTag.kartaview: "https://kv/old.jpg", PhotoTag.streetImages: "https://pnx/new.jpg"] + XCTAssertEqual(PhotoTag.existingURL(in: tags), "https://pnx/new.jpg") + } + + func testAKartaviewOnlyPhotoIsStillFound() { + XCTAssertEqual(PhotoTag.existingURL(in: [PhotoTag.kartaview: "https://kv/old.jpg"]), "https://kv/old.jpg") + } + + func testEmptyValuesCountAsNoPhoto() { + let tags = [PhotoTag.streetImages: "", PhotoTag.kartaview: ""] + XCTAssertNil(PhotoTag.existingURL(in: tags)) + XCTAssertTrue(PhotoTag.keysWithPhoto(in: tags).isEmpty) + } + + func testTheTagNamesMatchWhatOtherToolsRead() { + XCTAssertEqual(PhotoTag.streetImages, "ext:street_images") + XCTAssertEqual(PhotoTag.kartaview, "ext:kartaview_url") + } +} + +final class PhotoThumbnailTests: XCTestCase { + + func testAPanoramaxPhotoUsesItsThumbnail() { + let hd = "https://pnx.test/api/pictures/c0ffee00-1111-4222-8333-444455556666/hd.jpg" + XCTAssertEqual(PhotoTag.thumbnailURL(for: hd), "https://pnx.test/api/pictures/c0ffee00-1111-4222-8333-444455556666/thumb.jpg") + } + + func testTheStandardSizeAlsoMapsToTheThumbnail() { + XCTAssertEqual(PhotoTag.thumbnailURL(for: "http://localhost:5001/api/pictures/abc/sd.jpg"), + "http://localhost:5001/api/pictures/abc/thumb.jpg") + } + + func testAPanoramaxInstanceUnderASubpathKeepsItsPrefix() { + XCTAssertEqual(PhotoTag.thumbnailURL(for: "https://host/panoramax/api/pictures/abc/hd.jpg"), + "https://host/panoramax/api/pictures/abc/thumb.jpg") + } + + func testOtherURLsAreLeftAlone() { + for url in ["https://cdn.kartaview.org/pr:sharp/photo-777001.jpg", + "https://example.com/pictures/abc/hd.jpg", + "not a url at all"] { + XCTAssertEqual(PhotoTag.thumbnailURL(for: url), url) + } + } +} + +// MARK: - Photo heading (GPSImgDirection) + +final class PhotoHeadingTests: AppTestCase { + + private let spot = CLLocationCoordinate2D(latitude: 47.6553, longitude: -122.3035) + private let directionField = "override_Exif.GPSInfo.GPSImgDirection" + private let directionRefField = "override_Exif.GPSInfo.GPSImgDirectionRef" + + func testTheHeadingIsSentAsAWholeDegreeRationalFromTrueNorth() { + XCTAssertEqual(PhotoHeading.overrideFields(for: 87.4), [directionField: "87/1", directionRefField: "T"]) + XCTAssertEqual(PhotoHeading.overrideFields(for: 359.7)[directionField], "0/1", "rounds up to north, never 360") + XCTAssertEqual(PhotoHeading.overrideFields(for: 0)[directionField], "0/1") + XCTAssertEqual(PhotoHeading.overrideFields(for: 450)[directionField], "90/1") + } + + func testAnUnknownHeadingSendsNothing() { + XCTAssertTrue(PhotoHeading.overrideFields(for: nil).isEmpty) + XCTAssertTrue(PhotoHeading.overrideFields(for: -1).isEmpty, "Core Location's 'invalid' value") + XCTAssertTrue(PhotoHeading.overrideFields(for: .nan).isEmpty) + } + + func testTheHeadingSurvivesBeingQueuedOnDisk() throws { + let image = makeImage() + image.captureHeading = 123.5 + for (save, load, delete) in [ + (QuestImageStorage.shared.save, QuestImageStorage.shared.load, QuestImageStorage.shared.delete), + (NoteImageStorage.shared.save, NoteImageStorage.shared.load, NoteImageStorage.shared.delete), + (FeatureImageStorage.shared.save, FeatureImageStorage.shared.load, FeatureImageStorage.shared.delete) + ] as [((UIImage) throws -> String, (String) -> UIImage?, ([String]) -> Void)] { + let path = try save(image) + let loaded = try XCTUnwrap(load(path)) + XCTAssertEqual(try XCTUnwrap(loaded.captureHeading), 123.5, accuracy: 0.01) + XCTAssertEqual(loaded.size, image.size) + delete([path]) + } + } + + func testAPhotoWithoutAHeadingLoadsWithoutOne() throws { + let path = try NoteImageStorage.shared.save(makeImage()) + XCTAssertNil(NoteImageStorage.shared.load(path: path)?.captureHeading) + NoteImageStorage.shared.delete(paths: [path]) + } + + func testTheUploaderSendsTheHeadingOnlyWhenKnown() async throws { + MockServer.installPanoramax(.ok) + let uploader = PhotoUploadService.makePanoramaxUploader() + _ = try await uploader.upload(imageData: Data("jpeg".utf8), coordinate: spot, heading: 271.6) + _ = try await uploader.upload(imageData: Data("jpeg".utf8), coordinate: spot) + + let bodies = MockHTTP.requests(path: MockServer.panoramaxFilesPath, method: "POST").map(\.bodyString) + XCTAssertEqual(bodies.count, 2) + XCTAssertTrue(bodies[0].contains("name=\"\(directionField)\"\r\n\r\n272/1\r\n")) + XCTAssertTrue(bodies[0].contains("name=\"\(directionRefField)\"\r\n\r\nT\r\n")) + XCTAssertFalse(bodies[1].contains("GPSImgDirection")) + } + + func testAQueuedNotePhotoUploadsWithTheHeadingItWasTakenAt() throws { + signIn(workspaceID: "222") + let events = recordSheetEvents() + MockServer.installNotes() + MockServer.installPanoramax(.ok) + let image = makeImage() + image.captureHeading = 45 + NotesSubmissionManager.submit(NoteDraft(noteText: "facing north-east", images: [image], coordinates: spot)) + waitUntil(timeout: 12, "the note never synced") { events.contains("noteSubmitted") } + + let body = try XCTUnwrap(MockHTTP.requests(path: MockServer.panoramaxFilesPath).first).bodyString + XCTAssertTrue(body.contains("name=\"\(directionField)\"\r\n\r\n45/1\r\n")) + } +} + +/// Thread-safe call counter for stubs that answer differently on later calls. +final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + /// Adds one and returns the new count. + func increment() -> Int { + lock.lock(); defer { lock.unlock() } + value += 1 + return value + } +} diff --git a/GoInfoGame/GoInfoGameTests/TestSupport/AppTestCase.swift b/GoInfoGame/GoInfoGameTests/TestSupport/AppTestCase.swift index ddfef100..38464577 100644 --- a/GoInfoGame/GoInfoGameTests/TestSupport/AppTestCase.swift +++ b/GoInfoGame/GoInfoGameTests/TestSupport/AppTestCase.swift @@ -49,6 +49,11 @@ class AppTestCase: XCTestCase { repo.customIcons = [] repo.recencyPeriodDays = nil SessionManager.shared.lastLoginPassword = nil + // Photo uploads go to a stub Panoramax instance, never the one in a local Secrets.xcconfig. + PhotoUploadService.makePanoramaxUploader = { + PanoramaxUploader(baseURL: MockServer.panoramaxBaseURL, token: MockServer.panoramaxToken) + } + PhotoUploadService.semanticsRetryDelays = [0, 0, 0] } // MARK: Session helpers diff --git a/GoInfoGame/GoInfoGameTests/TestSupport/MockHTTP.swift b/GoInfoGame/GoInfoGameTests/TestSupport/MockHTTP.swift index 67d0ff34..6a193a0e 100644 --- a/GoInfoGame/GoInfoGameTests/TestSupport/MockHTTP.swift +++ b/GoInfoGame/GoInfoGameTests/TestSupport/MockHTTP.swift @@ -55,7 +55,8 @@ enum MockHTTP { /// The URL path as sent - unlike `URL.path`, this keeps a trailing slash ("/sequence/"). var path: String { URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.path ?? request.url!.path } var method: String { request.httpMethod ?? "GET" } - var bodyString: String { body.flatMap { String(data: $0, encoding: .utf8) } ?? "" } + /// Lossy, so a multipart body with binary JPEG bytes still exposes its text fields. + var bodyString: String { body.map { String(decoding: $0, as: UTF8.self) } ?? "" } func header(_ name: String) -> String? { request.value(forHTTPHeaderField: name) } } @@ -212,7 +213,7 @@ enum MockHTTP { // MARK: Fixtures /// Loads a JSON fixture. The UI tests' shared fixtures (GoInfoGame/DebugSupport/MockResponses) - /// ship inside the host app bundle; fixtures only unit tests need (e.g. Kartaview uploads) live + /// ship inside the host app bundle; fixtures only unit tests need live /// in this test bundle (GoInfoGameTests/Fixtures) and are looked up as a fallback. static func fixture(_ name: String, file: StaticString = #filePath, line: UInt = #line) -> Data { for bundle in [Bundle.main, Bundle(for: FixtureBundleAnchor.self)] { diff --git a/GoInfoGame/GoInfoGameTests/TestSupport/MockServer.swift b/GoInfoGame/GoInfoGameTests/TestSupport/MockServer.swift index d001a1b8..c1166d99 100644 --- a/GoInfoGame/GoInfoGameTests/TestSupport/MockServer.swift +++ b/GoInfoGame/GoInfoGameTests/TestSupport/MockServer.swift @@ -110,42 +110,43 @@ enum MockServer { } } - // MARK: Kartaview (photo uploads) + // MARK: Panoramax (photo uploads) - static let kartaviewPhotoURL = "https://cdn.kartaview.org/pr:sharp/unit-test-photo-777001.jpg" + static let panoramaxBaseURL = "https://panoramax.test" + static let panoramaxToken = "test-panoramax-token" + static let panoramaxUploadSetID = "8f1d9c2e-0000-4000-8000-000000000001" + static let panoramaxPictureID = "c0ffee00-1111-4222-8333-444455556666" + static let panoramaxPhotoURL = "\(panoramaxBaseURL)/api/pictures/\(panoramaxPictureID)/hd.jpg" + static let panoramaxFilesPath = "/api/upload_sets/\(panoramaxUploadSetID)/files" + static let panoramaxPicturePath = "/api/pictures/\(panoramaxPictureID)" - enum Kartaview { + enum Panoramax { case ok - case createFails // sequence creation rejected - case createStatusNot200 // 200 HTTP but the API's own status says failure - case uploadFails // photo upload answers 500 - case uploadStatusNot200 - case finishFails // "finished uploading" answers 500 - case finishStatusNot200 - case lookupWithoutURL // the hosted-URL lookup never produces a URL + case rejected // 202, but the body says the photo was not accepted + case unauthorized // upload set creation answers 401 + case offline // the photo upload never reaches the server } - static func installKartaview(_ mode: Kartaview = .ok) { - switch mode { - case .createFails: MockHTTP.stub("POST", path: "/1.0/sequence/", reply: .statusWithBody(500, "boom")) - case .createStatusNot200: MockHTTP.stub("POST", path: "/1.0/sequence/", reply: .json(withHTTPStatus: 401, of: "KartaviewSequenceCreated")) - default: MockHTTP.stub("POST", path: "/1.0/sequence/", reply: .fixture("KartaviewSequenceCreated")) - } - switch mode { - case .uploadFails: MockHTTP.stub("POST", path: "/1.0/photo/", reply: .statusWithBody(500, "boom")) - case .uploadStatusNot200: MockHTTP.stub("POST", path: "/1.0/photo/", reply: .json(#"{"status":{"apiCode":660,"apiMessage":"no","httpCode":403,"httpMessage":"no"},"osv":{"photo":{"id":"1","sequenceId":"1","dateAdded":"d","sequenceIndex":"1","photoName":"p","lat":"0","lng":"0","path":"p"}}}"#)) - default: MockHTTP.stub("POST", path: "/1.0/photo/", reply: .fixture("KartaviewPhotoUploaded")) + static func installPanoramax(_ mode: Panoramax = .ok) { + if mode == .unauthorized { + MockHTTP.stub("POST", path: "/api/upload_sets", + reply: .statusWithBody(401, #"{"details":{"error":"Impossible to decode token"},"message":"Token not valid","status_code":401}"#)) + return } + MockHTTP.stub("POST", path: "/api/upload_sets", reply: .json(#"{"id":"\#(panoramaxUploadSetID)","title":"GoInfoGame"}"#)) switch mode { - case .finishFails: MockHTTP.stub("POST", path: "/1.0/sequence/finished-uploading/", reply: .statusWithBody(500, "boom")) - case .finishStatusNot200: MockHTTP.stub("POST", path: "/1.0/sequence/finished-uploading/", reply: .fixture("KartaviewStatusFailure")) - default: MockHTTP.stub("POST", path: "/1.0/sequence/finished-uploading/", reply: .fixture("KartaviewUploadFinished")) - } - if mode == .lookupWithoutURL { - MockHTTP.stub("GET", pathRegex: #"^/2\.0/photo/\d+$"#, reply: .fixture("KartaviewPhotoLookupEmpty")) - } else { - MockHTTP.stub("GET", pathRegex: #"^/2\.0/photo/\d+$"#, reply: .fixture("KartaviewPhotoLookup")) + case .rejected: + MockHTTP.stub("POST", path: panoramaxFilesPath, + reply: .json(#"{"file_name":"a.jpg","rejected":{"reason":"file_missing_mandatory_metadata","severity":"error","message":"No GPS coordinates","details":null}}"#, status: 202)) + case .offline: + MockHTTP.stub("POST", path: panoramaxFilesPath, reply: .offline) + default: + MockHTTP.stub("POST", path: panoramaxFilesPath, + reply: .json(#"{"file_name":"a.jpg","picture_id":"\#(panoramaxPictureID)","rejected":null}"#, status: 202)) } + // Adding semantics to the uploaded picture. + MockHTTP.stub("PATCH", path: panoramaxPicturePath, + reply: .json(#"{"id":"\#(panoramaxPictureID)","type":"Feature"}"#, status: 200)) } // MARK: Notes @@ -220,15 +221,3 @@ extension AppTestCase { return workspace } } - -extension MockHTTP.Reply { - /// A copy of a Kartaview fixture whose embedded `status.httpCode` is changed - the API answers - /// HTTP 200 and reports its own failure inside the body. - static func json(withHTTPStatus code: Int, of fixture: String) -> MockHTTP.Reply { - var object = (MockHTTP.fixtureJSON(fixture) as? [String: Any]) ?? [:] - var status = (object["status"] as? [String: Any]) ?? [:] - status["httpCode"] = code - object["status"] = status - return .json((try? JSONSerialization.data(withJSONObject: object)) ?? Data()) - } -} diff --git a/GoInfoGame/PhotoLookupResponse.swift b/GoInfoGame/PhotoLookupResponse.swift deleted file mode 100644 index bb6ea827..00000000 --- a/GoInfoGame/PhotoLookupResponse.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PhotoLookupResponse.swift -// GoInfoGame -// - -import Foundation - -// MARK: - PhotoLookupResponse -struct PhotoLookupResponse: Codable { - let result: PhotoLookupResult? -} - -// MARK: - Result -struct PhotoLookupResult: Codable { - let data: PhotoLookupData? -} - -// MARK: - Data -struct PhotoLookupData: Codable { - let imageLthUrl: String? -}