diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 4341ef564..e6232eaae 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -312,6 +312,7 @@ 30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; }; 30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */; }; 30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC1B7CB061C9865B2B91B56 /* QRCodeLoginScreenViewModel.swift */; }; + 310E1D9D246277A4FFA66162 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3989F50ECBC5AEE2E5A062DA /* MapLibreMapView.swift */; }; 311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */; }; 3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; }; 31CCF3159E5CE1C05AE5781B /* HTMLFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */; }; @@ -555,6 +556,7 @@ 5AA81A4E2D40A32A9E7F71F2 /* ShareExtensionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3ADF21BE301D0DA48F2A7E /* ShareExtensionView.swift */; }; 5AC5CD6D893073EE4D9A277E /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27299A36536DBF91AE8FA6 /* ShareExtensionViewController.swift */; }; 5AE6404C4FD4848ACCFF9EDC /* SecureBackupLogoutConfirmationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */; }; + 5B2CE8FE14CB16F2FCAFC4B2 /* MapLibreShim.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 44E8BAA982EC260E74CCCB22 /* MapLibreShim.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5B6E5AD224509E6C0B520D6E /* RoomMemberDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */; }; 5BC6C4ADFE7F2A795ECDE130 /* SecureBackupKeyBackupScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D4EEBE8C098BBADD10939 /* SecureBackupKeyBackupScreenCoordinator.swift */; }; 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C830A64609CBD152F06E0457 /* NotificationConstants.swift */; }; @@ -566,6 +568,7 @@ 5D52925FEB1B780C65B0529F /* PinnedEventsTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4F6D7000EDCD187E0989E7 /* PinnedEventsTimelineScreen.swift */; }; 5D53AE9342A4C06B704247ED /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */; }; 5D56CE09743C6B90C21B04C2 /* RoomMembersListScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E9E0929CEFA356090BE5FB8 /* RoomMembersListScreenViewModelTests.swift */; }; + 5D586C5CED7B7B086C61B84A /* MapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = 7E8600FD98955C620F83BFBA /* MapLibre */; }; 5D99F63CC88BB29383019FC6 /* ShareExtensionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC01A97A43BE07B9E94E43 /* ShareExtensionModels.swift */; }; 5DB4334CBBA142376FF5FFEC /* preview_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 200626E8353AB2729444F991 /* preview_image.jpg */; }; 5DD85A0FE3D85AEC3C7EFE36 /* DeveloperOptionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */; }; @@ -660,11 +663,13 @@ 6AEB650311F694A5702255C9 /* UserProfileScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B4932E4EFBC8FAC10972CD /* UserProfileScreenCoordinator.swift */; }; 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */; }; 6B3AB95A6993646A5081BFF9 /* TestablePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43F773904F87FF5ADFE4DD1 /* TestablePreview.swift */; }; + 6B47F2C6B11A1EDF0480DA71 /* CoordinateAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A8171DA78BA39F07D669B1 /* CoordinateAnimator.swift */; }; 6B61F5B27412ED4BC2F9769C /* test_audio.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 66B96842BF5F8ACA1AC84C55 /* test_audio.mp3 */; }; 6B80C24A52411EAF10E06E96 /* SecurityAndPrivacyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBADF8010C813D905C172CE /* SecurityAndPrivacyScreenModels.swift */; }; 6B8F03A85E83755D98DF0443 /* SearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4724D5CB16CFA5853EF8F9 /* SearchScreenViewModel.swift */; }; 6BA9214F31A24C7A84287A56 /* SentryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337AD37403015231CAE3E80B /* SentryEvent.swift */; }; 6BAE34CFA9821709CFE61E50 /* DeveloperOptionsScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F60FDB3AEFC60830BD289FE /* DeveloperOptionsScreenHook.swift */; }; + 6BD28DF22B18AA9269BD97D4 /* MapLibreAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0C086BE461902C44B608393 /* MapLibreAnnotation.swift */; }; 6BEB10499149444909EBE42F /* TrackedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */; }; 6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */; }; 6C8BAF1E91618D69E7D652B2 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */; }; @@ -708,7 +713,6 @@ 72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; }; 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; }; - 73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -806,6 +810,7 @@ 82434593648CB74121F1A821 /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C513A6CD99E6C3C163DA1E /* RowDivider.swift */; }; 8285FF4B2C2331758C437FF7 /* ReportContentScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */; }; 828EA5009557C2B9DCD4CA0F /* UserDiscoverySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */; }; + 82A33B40198D4EDD9F90493A /* MapViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8686F7CDEFAE2CBB3B9117 /* MapViewConfiguration.swift */; }; 82D1368B43020B55347E1743 /* GalleryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE0273EC14A781BBF715E37 /* GalleryGridView.swift */; }; 832A4EA1094B8FE423A08700 /* RoomChangeRolesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2A421198FD20AAAED20004 /* RoomChangeRolesScreen.swift */; }; 8358D145F9BF94F412BEDCA8 /* RoomRolesAndPermissionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE7969EBCAF078813E18EA1 /* RoomRolesAndPermissionsScreenModels.swift */; }; @@ -1055,6 +1060,7 @@ A6FFC4C5154C446BAD6B40D8 /* TimelineItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8520AFD6680CBAD388F6D927 /* TimelineItemProvider.swift */; }; A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; + A79634DCE52B5021F549E0E9 /* MapLibreShim.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44E8BAA982EC260E74CCCB22 /* MapLibreShim.framework */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A7DB75E090542331F6668A23 /* CreateRoomScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF19027E7FFA5E63D148873A /* CreateRoomScreenViewModel.swift */; }; A808DC3F72D15C6C5A52317E /* TimelineItemDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */; }; @@ -1336,12 +1342,14 @@ D8459AAD6969B1431ECBE990 /* UnsupportedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9E535B3388755B65C34CD10 /* UnsupportedRoomTimelineView.swift */; }; D8517B8EED58D24396FB71E7 /* DeactivateAccountScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */; }; D885B783B95AD7832D4EF5DD /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; + D89E46C3AAD462407D332046 /* MapLibreShimProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726D7EB0E16F7E4F6DD95395 /* MapLibreShimProtocol.swift */; }; D8CFA0EE46376F9FF04EEE45 /* TextRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4853C923A1AF43711D025EAF /* TextRoomTimelineView.swift */; }; D8F1462EA00AFC939FF9ACCA /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 203D1ACC20287F8986C959D3 /* target.yml */; }; D97C782FE0005995C36FA04A /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = E5E7D4EE7CA295E5039FDA21 /* portrait_test_video.mp4 */; }; D98B5EE8C4F5A2CE84687AE8 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352359663A0E52BA20761EE /* LoadableImage.swift */; }; DA10C99BA43A0F1E732F6274 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2711E5996016ABD6EAAEB58A /* LogLevel.swift */; }; + DA4ABC37ED49F6617091F142 /* MapLibreInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 842F63AA68E8EB410B1BA29F /* MapLibreInterface.framework */; }; DA7E867F5EAFF8E20B2EE3B6 /* SecureBackupScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3D16709ADD4F4BCC710B1E /* SecureBackupScreenModels.swift */; }; DA8AE352C8FFCD47327811CA /* LeaveSpaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822B5573BDF52CF220BF9A17 /* LeaveSpaceModels.swift */; }; DAF63A9CF9932CA8F6830F11 /* ShareExtensionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC01A97A43BE07B9E94E43 /* ShareExtensionModels.swift */; }; @@ -1352,6 +1360,7 @@ DBC3FDE1540B39702A117D8E /* RoomRolesAndPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DDC82DB6A84E700CD5DEC0 /* RoomRolesAndPermissionsTests.swift */; }; DBC8D1DBFE9F9CA7662BC8AA /* RoomPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974AEAF3FE0C577A6C04AD6E /* RoomPermissions.swift */; }; DC08ADC41E792086A340A8B3 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; }; + DC1321AC05EE491E5D5A9A50 /* MapLibreShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ABAF691BA268B93C06142A /* MapLibreShim.swift */; }; DC68E866D6E664B0D2B06E74 /* MockImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */; }; DC77E9DB2CFBE84A2BDF20C5 /* RoomRolesAndPermissionsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */; }; DCFE7CB3B9A104330BBB96AD /* AnalyticsPromptScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B67DF223EEB8DCAF178A1D4 /* AnalyticsPromptScreenCoordinator.swift */; }; @@ -1610,6 +1619,13 @@ remoteGlobalIDString = 19F0C845D67E9BEA4BE7133E; remoteInfo = ShareExtension; }; + 5F1D4E7C20485C444FA1D42F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AC22997D58D612146053154D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 392E7AA23D7BA2CEEB25CF73; + remoteInfo = MapLibreShim; + }; 6848AF4480814C5F810FB7EB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AC22997D58D612146053154D /* Project object */; @@ -1638,6 +1654,13 @@ remoteGlobalIDString = FEB53A5BC378C913769656D8; remoteInfo = NSE; }; + D96A382EE558F49A8070D92A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AC22997D58D612146053154D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 96435A765678A64A956EE59C; + remoteInfo = MapLibreInterface; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1661,6 +1684,7 @@ files = ( C9C562D85999E436C7265AF1 /* MatrixRustSDK in Embed Frameworks */, 874704A177B0A6D78E037908 /* MapLibreInterface.framework in Embed Frameworks */, + 5B2CE8FE14CB16F2FCAFC4B2 /* MapLibreShim.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -1772,6 +1796,7 @@ 105429F29096729EDD3152CF /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/SAS.strings; sourceTree = ""; }; 1059E2AE7878CF7820592637 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = ""; }; + 10A8171DA78BA39F07D669B1 /* CoordinateAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateAnimator.swift; sourceTree = ""; }; 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderProtocol.swift; sourceTree = ""; }; 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackedUserDefaults.swift; sourceTree = ""; }; 10F130DF775CE6BC51A4E392 /* AppLockSetupBiometricsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenModels.swift; sourceTree = ""; }; @@ -2009,6 +2034,7 @@ 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreen.swift; sourceTree = ""; }; 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenScreenModelProtocol.swift; sourceTree = ""; }; 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadableFrameModifier.swift; sourceTree = ""; }; + 3989F50ECBC5AEE2E5A062DA /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = ""; }; 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerMock.swift; sourceTree = ""; }; 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppManager.swift; sourceTree = ""; }; 3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; @@ -2080,6 +2106,7 @@ 44C314C00533E2C297796B60 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; 44C70BD39FC34A9FD2467540 /* SearchScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelProtocol.swift; sourceTree = ""; }; 44DDC82DB6A84E700CD5DEC0 /* RoomRolesAndPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsTests.swift; sourceTree = ""; }; + 44E8BAA982EC260E74CCCB22 /* MapLibreShim.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MapLibreShim.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 44ECC9D66400727DFFEE12E8 /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; 450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; 4525E8C0FBDD27D1ACE90952 /* SecureBackupKeyBackupScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2326,6 +2353,7 @@ 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHistorySharingState.swift; sourceTree = ""; }; 723B055A57857BFF0F18D9CB /* test_rotated_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = test_rotated_image.jpg; sourceTree = ""; }; 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStatusView.swift; sourceTree = ""; }; + 726D7EB0E16F7E4F6DD95395 /* MapLibreShimProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreShimProtocol.swift; sourceTree = ""; }; 726E901DF76393E335FD7E8E /* ManageAuthorizedSpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAuthorizedSpacesScreenViewModel.swift; sourceTree = ""; }; 72C4A2D279386A811BDC6DAE /* LinkNewDeviceScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreen.swift; sourceTree = ""; }; 72D03D36422177EF01905D20 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; @@ -2476,6 +2504,7 @@ 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenViewModelProtocol.swift; sourceTree = ""; }; 897DF5E9A70CE05A632FC8AF /* UTType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTType.swift; sourceTree = ""; }; 89AAEA70CFF3284920811941 /* RoomChangePermissionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreen.swift; sourceTree = ""; }; + 89ABAF691BA268B93C06142A /* MapLibreShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreShim.swift; sourceTree = ""; }; 89BB11A792EF6F70B95B467E /* EncryptionResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetTests.swift; sourceTree = ""; }; 89D7846847BA9D2C5346ED9C /* EditAvatarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAvatarButtonStyle.swift; sourceTree = ""; }; 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormButtonStyles.swift; sourceTree = ""; }; @@ -2656,6 +2685,7 @@ A9FC06B3414D3BBD709E72E4 /* CLLocationCoordinate2D.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLLocationCoordinate2D.swift; sourceTree = ""; }; AA12A7F5EF5C6D0B992869ED /* LiveLocationShareProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationShareProxy.swift; sourceTree = ""; }; AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = ""; }; + AA8686F7CDEFAE2CBB3B9117 /* MapViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewConfiguration.swift; sourceTree = ""; }; AAC9344689121887B74877AF /* UnitTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AACE9B8E1A4AE79A7E2914F6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = es; path = es.lproj/Localizable.stringsdict; sourceTree = ""; }; AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserProfile+Mock.swift"; sourceTree = ""; }; @@ -2971,6 +3001,7 @@ DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineItem.swift; sourceTree = ""; }; DFFB0E7C6D8E190AFA0176DC /* uz */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uz; path = uz.lproj/Localizable.stringsdict; sourceTree = ""; }; E06AAD6D9D3F5833E7A5A2F9 /* RoomListFilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListFilterModels.swift; sourceTree = ""; }; + E0C086BE461902C44B608393 /* MapLibreAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreAnnotation.swift; sourceTree = ""; }; E0FCA0957FAA0E15A9F5579D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Untranslated.stringsdict; sourceTree = ""; }; E0FF9CB3EFA753277291F609 /* EncryptionResetScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenCoordinator.swift; sourceTree = ""; }; E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = ""; }; @@ -3127,7 +3158,6 @@ FA393404F96DFB505B86E913 /* CurrentValuePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisherTests.swift; sourceTree = ""; }; FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkTests.swift; sourceTree = ""; }; FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreviewsDictionary.swift; sourceTree = ""; }; - FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateAnimator.swift; sourceTree = ""; }; FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = ""; }; FABAC5C4373B0EC24D399663 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/SAS.strings"; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; @@ -3193,6 +3223,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B2E8191126927DDE67256B6E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DA4ABC37ED49F6617091F142 /* MapLibreInterface.framework in Frameworks */, + 5D586C5CED7B7B086C61B84A /* MapLibre in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF59B36A7B2DB184B62826F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -3268,6 +3307,7 @@ FC10228E73323BDC09526F97 /* LRUCache in Frameworks */, EAC6FE2CD4F50A43068ADCD8 /* Mantis in Frameworks */, 76D4D5312ED8E42B35C29FA3 /* MapLibreInterface.framework in Frameworks */, + A79634DCE52B5021F549E0E9 /* MapLibreShim.framework in Frameworks */, 754602A7B2AAD443C4228ED4 /* MapLibre in Frameworks */, B0CB16349B96262AA65A04AF /* PostHog in Frameworks */, 36AD4DD4C798E22584ED3200 /* SwiftState in Frameworks */, @@ -3651,6 +3691,14 @@ path = MediaUploadPreviewScreen; sourceTree = ""; }; + 2368DD797236140F1D21429C /* MapLibreShim */ = { + isa = PBXGroup; + children = ( + 57A8D2B00E79926C6E5E6F49 /* Sources */, + ); + path = MapLibreShim; + sourceTree = ""; + }; 24FBB8F76D6435033CD07747 /* ManageAuthorizedSpacesScreen */ = { isa = PBXGroup; children = ( @@ -3746,6 +3794,7 @@ isa = PBXGroup; children = ( 2F311A9BA03995E736E065DE /* MapLibreInterface */, + 2368DD797236140F1D21429C /* MapLibreShim */, ); path = MapLibre; sourceTree = ""; @@ -4700,6 +4749,17 @@ path = AppLockSetupPINScreen; sourceTree = ""; }; + 57A8D2B00E79926C6E5E6F49 /* Sources */ = { + isa = PBXGroup; + children = ( + 10A8171DA78BA39F07D669B1 /* CoordinateAnimator.swift */, + E0C086BE461902C44B608393 /* MapLibreAnnotation.swift */, + 3989F50ECBC5AEE2E5A062DA /* MapLibreMapView.swift */, + 89ABAF691BA268B93C06142A /* MapLibreShim.swift */, + ); + path = Sources; + sourceTree = ""; + }; 593C7129C5927E25AD8B688F /* FlowCoordinators */ = { isa = PBXGroup; children = ( @@ -4926,6 +4986,7 @@ 9C7F7DE62D33C6A26CBFCD72 /* IntegrationTests.xctest */, 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */, 842F63AA68E8EB410B1BA29F /* MapLibreInterface.framework */, + 44E8BAA982EC260E74CCCB22 /* MapLibreShim.framework */, 0D8F620C8B314840D8602E3F /* NSE.appex */, D95E8C0EFEC0C6F96EDAA71A /* PreviewTests.xctest */, 3D8BEEFCA07BEA43F4F4BF77 /* ShareExtension.appex */, @@ -6090,7 +6151,9 @@ A7793413E64FE8831FE95F57 /* Sources */ = { isa = PBXGroup; children = ( + 726D7EB0E16F7E4F6DD95395 /* MapLibreShimProtocol.swift */, 33FF3E6EED8FC58755AF3A42 /* MapModels.swift */, + AA8686F7CDEFAE2CBB3B9117 /* MapViewConfiguration.swift */, ); path = Sources; sourceTree = ""; @@ -6468,7 +6531,6 @@ C17C3586C93F3A314C1CC318 /* MapLibre */ = { isa = PBXGroup; children = ( - FA74F57B0DA3B9A9DD51F691 /* CoordinateAnimator.swift */, AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */, 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */, B81B6170DB690013CEB646F4 /* MapLibreModels.swift */, @@ -7364,6 +7426,26 @@ productReference = AAC9344689121887B74877AF /* UnitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 392E7AA23D7BA2CEEB25CF73 /* MapLibreShim */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4986BC84F7961B6E169BC9A2 /* Build configuration list for PBXNativeTarget "MapLibreShim" */; + buildPhases = ( + 2258F3E0D784959ADD4FCE50 /* Sources */, + B2E8191126927DDE67256B6E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 233E31868DA9FF26EAB3044D /* PBXTargetDependency */, + ); + name = MapLibreShim; + packageProductDependencies = ( + 7E8600FD98955C620F83BFBA /* MapLibre */, + ); + productName = MapLibreShim; + productReference = 44E8BAA982EC260E74CCCB22 /* MapLibreShim.framework */; + productType = "com.apple.product-type.framework"; + }; 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */ = { isa = PBXNativeTarget; buildConfigurationList = 4272AFBE93EEF7B03D9B9ECD /* Build configuration list for PBXNativeTarget "MatrixRustSDKMocks" */; @@ -7467,6 +7549,7 @@ 58C473A5DEA945AACFEA8E9F /* PBXTargetDependency */, 2E32BC489F482046B8B1460F /* PBXTargetDependency */, 0C42CEF186BBA0F3DBC17B85 /* PBXTargetDependency */, + 36A05892F02FA9721A47871F /* PBXTargetDependency */, ); name = ElementX; packageProductDependencies = ( @@ -7580,6 +7663,9 @@ 32C23C8D224D46EFE62AFAD0 = { DevelopmentTeam = 7J4U792NQT; }; + 392E7AA23D7BA2CEEB25CF73 = { + DevelopmentTeam = 7J4U792NQT; + }; 676A87694F0F247B2AF1A142 = { DevelopmentTeam = 7J4U792NQT; }; @@ -7694,6 +7780,7 @@ projectRoot = ""; targets = ( 96435A765678A64A956EE59C /* MapLibreInterface */, + 392E7AA23D7BA2CEEB25CF73 /* MapLibreShim */, C0FAEB81CFD9776CD78CE489 /* ElementX */, FEB53A5BC378C913769656D8 /* NSE */, 19F0C845D67E9BEA4BE7133E /* ShareExtension */, @@ -8230,11 +8317,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2258F3E0D784959ADD4FCE50 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6B47F2C6B11A1EDF0480DA71 /* CoordinateAnimator.swift in Sources */, + 6BD28DF22B18AA9269BD97D4 /* MapLibreAnnotation.swift in Sources */, + 310E1D9D246277A4FFA66162 /* MapLibreMapView.swift in Sources */, + DC1321AC05EE491E5D5A9A50 /* MapLibreShim.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 294638399D803C4F2154D2B7 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D89E46C3AAD462407D332046 /* MapLibreShimProtocol.swift in Sources */, A05D6ACD38082D3E0A14D63E /* MapModels.swift in Sources */, + 82A33B40198D4EDD9F90493A /* MapViewConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -8510,7 +8610,6 @@ E8F88136C8E69D9E69621E19 /* ContentScannerServiceProtocol.swift in Sources */, 9DD0DFFEFCA51AE241B637D1 /* ContentScanningFailureView.swift in Sources */, 681FA9A37E82A14EDFB22D56 /* ContentScanningView.swift in Sources */, - 73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */, C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */, 633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */, CE8296D4AD30DDC6D0C67A74 /* CreateRoomScreen.swift in Sources */, @@ -9474,6 +9573,11 @@ target = C0FAEB81CFD9776CD78CE489 /* ElementX */; targetProxy = 4D8DD8FE84794CA168A8499A /* PBXContainerItemProxy */; }; + 233E31868DA9FF26EAB3044D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 96435A765678A64A956EE59C /* MapLibreInterface */; + targetProxy = D96A382EE558F49A8070D92A /* PBXContainerItemProxy */; + }; 2C29670603B37E38705D5FF1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FEB53A5BC378C913769656D8 /* NSE */; @@ -9484,6 +9588,11 @@ target = 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */; targetProxy = 2324965D972BC4812FE0F380 /* PBXContainerItemProxy */; }; + 36A05892F02FA9721A47871F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 392E7AA23D7BA2CEEB25CF73 /* MapLibreShim */; + targetProxy = 5F1D4E7C20485C444FA1D42F /* PBXContainerItemProxy */; + }; 421359F1BC0A1816DD34A2BB /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C0FAEB81CFD9776CD78CE489 /* ElementX */; @@ -9929,6 +10038,32 @@ }; name = Debug; }; + 68ED112958B6D89CEFACC03F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_BUNDLE_IDENTIFIER).maplibre-shim"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.2; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; 6EE786F14472D5CCF8DD0980 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -10252,6 +10387,32 @@ }; name = Debug; }; + A84BC5F889292CF399F36B08 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_BUNDLE_IDENTIFIER).maplibre-shim"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.2; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; AAE81BF8DCDB30B237B10C3E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -10364,6 +10525,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 4986BC84F7961B6E169BC9A2 /* Build configuration list for PBXNativeTarget "MapLibreShim" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68ED112958B6D89CEFACC03F /* Debug */, + A84BC5F889292CF399F36B08 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 79663128986C62EFAC289176 /* Build configuration list for PBXNativeTarget "UnitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -10876,6 +11046,11 @@ package = E9C4F3A12AA1F65C13A8C8EB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; productName = SnapshotTesting; }; + 7E8600FD98955C620F83BFBA /* MapLibre */ = { + isa = XCSwiftPackageProductDependency; + package = 0CBF57301AA172C21F76CE86 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */; + productName = MapLibre; + }; 800631D7250B7F93195035F1 /* KeychainAccess */ = { isa = XCSwiftPackageProductDependency; package = 61916C63E3F5BD900F08DA0C /* XCRemoteSwiftPackageReference "KeychainAccess" */; diff --git a/ElementX/MapLibre/MapLibreInterface/Sources/MapLibreShimProtocol.swift b/ElementX/MapLibre/MapLibreInterface/Sources/MapLibreShimProtocol.swift new file mode 100644 index 000000000..da258928c --- /dev/null +++ b/ElementX/MapLibre/MapLibreInterface/Sources/MapLibreShimProtocol.swift @@ -0,0 +1,19 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// Implemented by MapLibreShim's entry point, which the app instantiates after +/// `dlopen`-ing the framework. +public protocol MapLibreShimProtocol { + /// Routes MapLibre's logs into the app's logger. Call once after loading the shim. + func configureLogging(_ handler: @escaping @Sendable (MapLogSeverity, String) -> Void) + + /// Builds the interactive map view. The app-side `MapLibreMapView` forwards every + /// body evaluation here so SwiftUI updates flow through to the underlying map. + func makeMapView(configuration: MapViewConfiguration) -> AnyView +} diff --git a/ElementX/MapLibre/MapLibreInterface/Sources/MapModels.swift b/ElementX/MapLibre/MapLibreInterface/Sources/MapModels.swift index 383bba5c7..b2eab04bd 100644 --- a/ElementX/MapLibre/MapLibreInterface/Sources/MapModels.swift +++ b/ElementX/MapLibre/MapLibreInterface/Sources/MapModels.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import CoreLocation import Foundation /** @@ -33,3 +34,23 @@ public enum MapLibreError: Error, Hashable { case failedLoadingMap case failedLocatingUser } + +/// The severity of a map log message forwarded to the app. +public enum MapLogSeverity { + case error + case warning + case info + case debug + case verbose +} + +/// A marker shown on the map, identified so that it can be moved in place when its coordinate changes. +public struct MapAnnotation { + public let id: String + public let coordinate: CLLocationCoordinate2D + + public init(id: String, coordinate: CLLocationCoordinate2D) { + self.id = id + self.coordinate = coordinate + } +} diff --git a/ElementX/MapLibre/MapLibreInterface/Sources/MapViewConfiguration.swift b/ElementX/MapLibre/MapLibreInterface/Sources/MapViewConfiguration.swift new file mode 100644 index 000000000..17d8df8a3 --- /dev/null +++ b/ElementX/MapLibre/MapLibreInterface/Sources/MapViewConfiguration.swift @@ -0,0 +1,76 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import CoreLocation +import SwiftUI + +/// Everything the map implementation needs in order to render, expressed without app +/// types so that the implementation framework can be loaded on demand. +public struct MapViewConfiguration { + /// the final zoom level used when the first user location emit + public let zoomLevel: Double + /// The initial zoom level used when the map it firstly loaded and the user location is not yet available, in case of annotations this property is not being used + public let initialZoomLevel: Double + + /// The initial map center + public let mapCenter: CLLocationCoordinate2D + + /// Map annotations + public let annotations: [MapAnnotation] + + /// The style to load the map with, for either the dark or the light appearance. + public let styleURL: (_ isDark: Bool) -> URL? + + public let tintColor: UIColor + + /// The content of the marker representing the annotation with the given id. + public let markerView: (_ annotationID: String) -> AnyView? + + /// Behavior mode of the current user's location, can be hidden, only shown and shown following the user + public let showsUserLocationMode: Binding + /// Bind view errors if any + public let error: Binding + /// Coordinate of the center of the map + public let mapCenterCoordinate: Binding + public let hasLoadedUserLocation: Binding + public let isLocationAuthorized: Binding + /// The radius of uncertainty for the location, measured in meters. + public let geolocationUncertainty: Binding + + /// Called when the user pan on the map + public let userDidPan: (() -> Void)? + + public init(zoomLevel: Double, + initialZoomLevel: Double, + mapCenter: CLLocationCoordinate2D, + annotations: [MapAnnotation] = [], + styleURL: @escaping (_ isDark: Bool) -> URL?, + tintColor: UIColor, + markerView: @escaping (_ annotationID: String) -> AnyView?, + showsUserLocationMode: Binding, + error: Binding, + mapCenterCoordinate: Binding, + hasLoadedUserLocation: Binding, + isLocationAuthorized: Binding, + geolocationUncertainty: Binding, + userDidPan: (() -> Void)? = nil) { + self.zoomLevel = zoomLevel + self.initialZoomLevel = initialZoomLevel + self.mapCenter = mapCenter + self.annotations = annotations + self.styleURL = styleURL + self.tintColor = tintColor + self.markerView = markerView + self.showsUserLocationMode = showsUserLocationMode + self.error = error + self.mapCenterCoordinate = mapCenterCoordinate + self.hasLoadedUserLocation = hasLoadedUserLocation + self.isLocationAuthorized = isLocationAuthorized + self.geolocationUncertainty = geolocationUncertainty + self.userDidPan = userDidPan + } +} diff --git a/ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift b/ElementX/MapLibre/MapLibreShim/Sources/CoordinateAnimator.swift similarity index 95% rename from ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift rename to ElementX/MapLibre/MapLibreShim/Sources/CoordinateAnimator.swift index 06e27b335..cc04acce2 100644 --- a/ElementX/Sources/Other/MapLibre/CoordinateAnimator.swift +++ b/ElementX/MapLibre/MapLibreShim/Sources/CoordinateAnimator.swift @@ -8,7 +8,7 @@ import CoreLocation import QuartzCore -/// Smoothly animates a `LocationAnnotation`'s coordinate from its current +/// Smoothly animates a `MapLibreAnnotation`'s coordinate from its current /// position to a new one using a `CADisplayLink` for frame-accurate updates. /// /// ## How it works @@ -26,7 +26,7 @@ import QuartzCore /// cancels the previous one and begins from the annotation's current position. final class CoordinateAnimator { private var displayLink: CADisplayLink? - private weak var annotation: LocationAnnotation? + private weak var annotation: MapLibreAnnotation? private let startCoordinate: CLLocationCoordinate2D private let endCoordinate: CLLocationCoordinate2D private let duration: CFTimeInterval @@ -36,7 +36,7 @@ final class CoordinateAnimator { /// while the display link is running. Keyed by annotation ID. private static var activeAnimators: [String: CoordinateAnimator] = [:] - private init(annotation: LocationAnnotation, + private init(annotation: MapLibreAnnotation, to end: CLLocationCoordinate2D, duration: CFTimeInterval) { self.annotation = annotation @@ -48,7 +48,7 @@ final class CoordinateAnimator { /// Starts animating the annotation's coordinate to `end` over `duration` seconds. /// If the annotation is already being animated, the in-flight animation is /// cancelled and a new one starts from the current position. - static func animate(annotation: LocationAnnotation, + static func animate(annotation: MapLibreAnnotation, to end: CLLocationCoordinate2D, duration: CFTimeInterval) { guard annotation.coordinate.latitude != end.latitude diff --git a/ElementX/MapLibre/MapLibreShim/Sources/MapLibreAnnotation.swift b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreAnnotation.swift new file mode 100644 index 000000000..bc52e9121 --- /dev/null +++ b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreAnnotation.swift @@ -0,0 +1,60 @@ +// +// Copyright 2026 Element Creations Ltd. +// Copyright 2023-2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import CoreLocation +import MapLibre +import MapLibreInterface +import SwiftUI + +final class MapLibreAnnotation: NSObject, MLNAnnotation, Identifiable { + let id: String + // @objc dynamic is required to make animations work + @objc dynamic var coordinate: CLLocationCoordinate2D + + // MARK: - Setup + + init(_ annotation: MapAnnotation) { + id = annotation.id + coordinate = annotation.coordinate + super.init() + } +} + +final class MapLibreAnnotationView: MLNUserLocationAnnotationView { + private var hostingController: UIHostingController? + + // MARK: - Setup + + override init(annotation: MLNAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: + reuseIdentifier) + } + + convenience init(annotation: MapLibreAnnotation, content: AnyView?) { + self.init(annotation: annotation, reuseIdentifier: "\(Self.self)") + let hostingController = UIHostingController(rootView: content ?? AnyView(EmptyView())) + self.hostingController = hostingController + let view: UIView = hostingController.view + view.backgroundColor = .clear + view.anchorPoint = .init(x: 0.5, y: 1.0) + addSubview(view) + view.bounds.size = view.intrinsicContentSize + } + + func updateContent(_ content: AnyView?) { + hostingController?.rootView = content ?? AnyView(EmptyView()) + if let hostedView = hostingController?.view { + hostedView.bounds.size = hostedView.intrinsicContentSize + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } +} diff --git a/ElementX/MapLibre/MapLibreShim/Sources/MapLibreMapView.swift b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreMapView.swift new file mode 100644 index 000000000..53a966574 --- /dev/null +++ b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreMapView.swift @@ -0,0 +1,297 @@ +// +// Copyright 2026 Element Creations Ltd. +// Copyright 2023-2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import MapLibre +import MapLibreInterface +import SwiftUI + +struct MapLibreMapView: UIViewRepresentable { + // MARK: - Properties + + @Environment(\.colorScheme) private var colorScheme + + let configuration: MapViewConfiguration + + // MARK: - UIViewRepresentable + + func makeUIView(context: Context) -> MLNMapView { + let mapView = makeMapView() + mapView.delegate = context.coordinator + setupMap(mapView: mapView) + return mapView + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + // Don't set the same value twice. Otherwise, if there is an error loading the map, a loop + // is caused as the `error` binding being set, which triggers this update, which sets a + // new URL, which causes another error, and so it goes on round and round in a circle. + let dynamicMapURL = configuration.styleURL(colorScheme == .dark) + if mapView.styleURL != dynamicMapURL { + mapView.styleURL = dynamicMapURL + } + + // If the center coordinate was updated externally (not by the map itself), move the map. + // Not applied while following a marker, where the camera is driven by the marker's position. + if configuration.showsUserLocationMode.wrappedValue.followedMarkerID == nil, + let newCenter = configuration.mapCenterCoordinate.wrappedValue, + newCenter.latitude != context.coordinator.lastReportedCenter?.latitude + || newCenter.longitude != context.coordinator.lastReportedCenter?.longitude { + context.coordinator.lastReportedCenter = newCenter + mapView.setCenter(newCenter, animated: true) + } + + // Update existing annotation views with fresh SwiftUI content. + // This handles the case where the annotation's view data changes after + // the annotation was initially placed (e.g. user avatar loads asynchronously). + updateAnnotations(in: mapView) + + showUserLocation(in: mapView) + + context.coordinator.updateMarkerFollowing(in: mapView, markerID: configuration.showsUserLocationMode.wrappedValue.followedMarkerID) + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + // MARK: - Private + + private func setupMap(mapView: MLNMapView) { + mapView.addAnnotations(configuration.annotations.map(MapLibreAnnotation.init)) + mapView.zoomLevel = configuration.annotations.isEmpty ? configuration.initialZoomLevel : configuration.zoomLevel + mapView.centerCoordinate = configuration.mapCenter + } + + private func updateAnnotations(in mapView: MLNMapView) { + let existingByID = Dictionary(uniqueKeysWithValues: + (mapView.annotations ?? []).compactMap { $0 as? MapLibreAnnotation }.map { ($0.id, $0) }) + let updatedByID = Dictionary(uniqueKeysWithValues: configuration.annotations.map { ($0.id, $0) }) + + let existingIDs = Set(existingByID.keys) + let updatedIDs = Set(updatedByID.keys) + + // Remove annotations that are no longer present + let removedIDs = existingIDs.subtracting(updatedIDs) + if !removedIDs.isEmpty { + let toRemove = removedIDs.compactMap { existingByID[$0] } + mapView.removeAnnotations(toRemove) + } + + // Add new annotations + let addedIDs = updatedIDs.subtracting(existingIDs) + if !addedIDs.isEmpty { + let toAdd = addedIDs.compactMap { updatedByID[$0] }.map(MapLibreAnnotation.init) + mapView.addAnnotations(toAdd) + } + + // Update existing annotations that are still present + let keptIDs = existingIDs.intersection(updatedIDs) + for id in keptIDs { + guard let existingAnnotation = existingByID[id], + let updatedAnnotation = updatedByID[id] else { + continue + } + CoordinateAnimator.animate(annotation: existingAnnotation, + to: updatedAnnotation.coordinate, + duration: 1.0) + if let annotationView = mapView.view(for: existingAnnotation) as? MapLibreAnnotationView { + annotationView.updateContent(configuration.markerView(id)) + } + } + } + + private func makeMapView() -> MLNMapView { + let mapView = MLNMapView(frame: .zero, styleURL: configuration.styleURL(colorScheme == .dark)) + mapView.logoViewPosition = .topLeft + mapView.attributionButtonPosition = .topLeft + mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2) + mapView.tintColor = configuration.tintColor + mapView.allowsRotating = false + mapView.allowsTilting = false + return mapView + } + + private func showUserLocation(in mapView: MLNMapView) { + switch (configuration.showsUserLocationMode.wrappedValue, configuration.annotations) { + case (.showAndFollow, _): + mapView.userTrackingMode = .follow + case (.show, let annotations) where !annotations.isEmpty: + // In the show mode, if there are annotations, we check the authorizationStatus, + // if it's not determined, we wont prompt the user with a request for permissions, + // because they should be able to see the annotations without sharing their location information. + guard mapView.locationManager.authorizationStatus != .notDetermined else { return } + fallthrough + case (.show, _): + mapView.showsUserLocation = true + mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) + case (.hide, _), (.hideAndFollowMarker, _): + // In hideAndFollowMarker mode the camera following is handled by the coordinator. + mapView.showsUserLocation = false + mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) + } + } +} + +// MARK: - Coordinator + +extension MapLibreMapView { + class Coordinator: NSObject, MLNMapViewDelegate { + // MARK: - Properties + + var mapLibreView: MapLibreMapView + + private var previousUserLocation: MLNUserLocation? + /// Tracks the last center coordinate reported by the map (or set programmatically), + /// so that `updateUIView` can tell apart external binding changes from internal ones. + var lastReportedCenter: CLLocationCoordinate2D? + + /// The annotation the camera is currently locked on while in the `hideAndFollowMarker` mode. + private weak var followedAnnotation: MapLibreAnnotation? + /// Observes the followed annotation's coordinate, updated every frame by the `CoordinateAnimator`. + private var followedAnnotationObservation: NSKeyValueObservation? + + // MARK: - Setup + + init(_ mapLibreView: MapLibreMapView) { + self.mapLibreView = mapLibreView + } + + // MARK: - Marker following + + /// Keeps the camera locked on the marker with the given id, or stops following when nil. + func updateMarkerFollowing(in mapView: MLNMapView, markerID: String?) { + guard let markerID else { + stopFollowingMarker() + return + } + + guard let annotation = (mapView.annotations ?? []) + .compactMap({ $0 as? MapLibreAnnotation }) + .first(where: { $0.id == markerID }) else { + // The marker isn't on the map (yet), resolve it again on the next update. + stopFollowingMarker() + return + } + + guard annotation !== followedAnnotation else { return } + startFollowingMarker(annotation, in: mapView) + } + + private func startFollowingMarker(_ annotation: MapLibreAnnotation, in mapView: MLNMapView) { + followedAnnotation = annotation + followedAnnotationObservation = nil + + // Animate to the marker first, attaching the per-frame tracking only on completion + // so that it doesn't cancel the transition. + mapView.setCenter(annotation.coordinate, + zoomLevel: mapView.zoomLevel, + direction: -1, // negative value keeps the current direction + animated: true) { [weak self, weak annotation, weak mapView] in + guard let self, let annotation, let mapView, annotation === followedAnnotation else { return } + + // Mirror every animated coordinate update to keep the camera as smooth as the marker. + followedAnnotationObservation = annotation.observe(\.coordinate) { [weak mapView] annotation, _ in + // The coordinate is only ever updated by SwiftUI so KVO fires on the main actor. + MainActor.assumeIsolated { + mapView?.setCenter(annotation.coordinate, animated: false) + } + } + } + } + + private func stopFollowingMarker() { + followedAnnotation = nil + followedAnnotationObservation = nil + } + + // MARK: - MLNMapViewDelegate + + func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { + guard let annotation = annotation as? MapLibreAnnotation else { + return nil + } + return MapLibreAnnotationView(annotation: annotation, content: mapLibreView.configuration.markerView(annotation.id)) + } + + func mapViewDidFailLoadingMap(_ mapView: MLNMapView, withError error: Error) { + if mapLibreView.configuration.error.wrappedValue != .failedLoadingMap { + mapLibreView.configuration.error.wrappedValue = .failedLoadingMap + } + } + + func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) { + guard let userLocation else { return } + mapLibreView.configuration.hasLoadedUserLocation.wrappedValue = true + + if previousUserLocation == nil, mapLibreView.configuration.annotations.isEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.configuration.zoomLevel, animated: true) + } + } + + previousUserLocation = userLocation + updateGeolocationUncertainty(location: userLocation) + } + + func mapView(_ mapView: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) { + switch manager.authorizationStatus { + case .denied, .restricted: + mapLibreView.configuration.isLocationAuthorized.wrappedValue = false + case .authorizedAlways, .authorizedWhenInUse: + mapLibreView.configuration.isLocationAuthorized.wrappedValue = true + case .notDetermined: + mapLibreView.configuration.isLocationAuthorized.wrappedValue = nil + @unknown default: + break + } + } + + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { + // While following a marker, don't flood the center binding with per-frame camera updates. + // Leaving `lastReportedCenter` untouched avoids an external re-centering when following stops. + guard mapLibreView.configuration.showsUserLocationMode.wrappedValue.followedMarkerID == nil else { return } + + // Avoid `Publishing changes from within view update` warnings + DispatchQueue.main.async { [mapLibreView, weak self] in + let center = mapView.centerCoordinate + self?.lastReportedCenter = center + mapLibreView.configuration.mapCenterCoordinate.wrappedValue = center + } + } + + func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool { + // Send userDidPan only for gestures that actually change the map center. The reason + // is an option set and gestures can come combined with other reasons, so check for + // containment instead of an exact match. + let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate] + if !reason.isDisjoint(with: centerChangingGestures) { + // Stop following immediately, the camera would fight the gesture otherwise. + stopFollowingMarker() + mapLibreView.configuration.userDidPan?() + } + return true + } + + // MARK: Callout + + func mapView(_ mapView: MLNMapView, annotationCanShowCallout annotation: MLNAnnotation) -> Bool { + false + } + + // MARK: Private + + private func updateGeolocationUncertainty(location: MLNUserLocation) { + guard let clLocation = location.location, clLocation.horizontalAccuracy >= 0 else { + mapLibreView.configuration.geolocationUncertainty.wrappedValue = nil + return + } + + mapLibreView.configuration.geolocationUncertainty.wrappedValue = clLocation.horizontalAccuracy + } + } +} diff --git a/ElementX/MapLibre/MapLibreShim/Sources/MapLibreShim.swift b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreShim.swift new file mode 100644 index 000000000..021df86b7 --- /dev/null +++ b/ElementX/MapLibre/MapLibreShim/Sources/MapLibreShim.swift @@ -0,0 +1,37 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import MapLibre +import MapLibreInterface +import SwiftUI + +/// The shim's entry point, vending the interactive map view to the app. +public final class MapLibreShim: NSObject, MapLibreShimProtocol { + public func configureLogging(_ handler: @escaping @Sendable (MapLogSeverity, String) -> Void) { + MLNLoggingConfiguration.shared.loggingLevel = .debug + MLNLoggingConfiguration.shared.handler = { loggingLevel, _, _, message in + switch loggingLevel { + case .error: + handler(.error, message) + case .warning: + handler(.warning, message) + case .info: + handler(.info, message) + case .debug: + handler(.debug, message) + case .verbose: + handler(.verbose, message) + default: + break + } + } + } + + public func makeMapView(configuration: MapViewConfiguration) -> AnyView { + AnyView(MapLibreMapView(configuration: configuration)) + } +} diff --git a/ElementX/MapLibre/MapLibreShim/SupportingFiles/target.yml b/ElementX/MapLibre/MapLibreShim/SupportingFiles/target.yml new file mode 100644 index 000000000..4e670d7d3 --- /dev/null +++ b/ElementX/MapLibre/MapLibreShim/SupportingFiles/target.yml @@ -0,0 +1,26 @@ +# The interactive map, split out of the app so MapLibre's static initialisers stay off the +# launch path: the app links only the tiny MapLibreInterface framework, while MapLibreShim (the +# only image linking MapLibre) is embedded unlinked and dlopen'd on first map use. +name: MapLibreShim + +targets: + MapLibreShim: + type: framework + platform: iOS + + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: $(BASE_BUNDLE_IDENTIFIER).maplibre-shim + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: 6.2 + SWIFT_APPROACHABLE_CONCURRENCY: YES + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor + + dependencies: + - target: MapLibreInterface + embed: false + - package: MapLibre + embed: false + + sources: + - path: ../Sources diff --git a/ElementX/Sources/Application/TargetConfiguration.swift b/ElementX/Sources/Application/TargetConfiguration.swift index 68daaf488..004892c43 100644 --- a/ElementX/Sources/Application/TargetConfiguration.swift +++ b/ElementX/Sources/Application/TargetConfiguration.swift @@ -8,9 +8,6 @@ import Combine import Foundation -#if IS_MAIN_APP -import MapLibre -#endif import MatrixRustSDK nonisolated enum Target: String { @@ -65,8 +62,6 @@ nonisolated enum Target: String { MXLog.configure(currentTarget: rawValue) - configureMapLibreIfAvailable() - let hookCancellable = rageshakeURL.publisher .sink { _ in appHooks.tracingHook.update(tracingConfiguration, with: rageshakeURL) @@ -75,28 +70,6 @@ nonisolated enum Target: String { return ConfigurationResult(hookCancellable: hookCancellable) } - private func configureMapLibreIfAvailable() { - #if IS_MAIN_APP - MLNLoggingConfiguration.shared.loggingLevel = .debug - MLNLoggingConfiguration.shared.handler = { loggingLevel, _, _, message in - switch loggingLevel { - case .error: - MXLog.error(message) - case .warning: - MXLog.warning(message) - case .info: - MXLog.info(message) - case .debug: - MXLog.debug(message) - case .verbose: - MXLog.verbose(message) - default: - break - } - } - #endif - } - /// The result of calling ``configure(logLevel:traceLogPacks:sentryURL:)``. /// This must be stored - see the docs on the configure method to learn more. struct ConfigurationResult { diff --git a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift index 72245d2a6..57a582473 100644 --- a/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift +++ b/ElementX/Sources/Other/MapLibre/LocationAnnotation.swift @@ -8,57 +8,9 @@ import CoreLocation import Foundation -import MapLibre -import SwiftUI -final class LocationAnnotation: NSObject, MLNAnnotation, Identifiable { +struct LocationAnnotation: Identifiable { let id: String - // @objc dynamic is required to make animations work - @objc dynamic var coordinate: CLLocationCoordinate2D - var kind: LocationMarkerKind - - // MARK: - Setup - - init(id: String, coordinate: CLLocationCoordinate2D, kind: LocationMarkerKind) { - self.id = id - self.coordinate = coordinate - self.kind = kind - super.init() - } -} - -final class LocationAnnotationView: MLNUserLocationAnnotationView { - private var hostingController: UIHostingController? - - // MARK: - Setup - - override init(annotation: MLNAnnotation?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: - reuseIdentifier) - } - - convenience init(annotation: LocationAnnotation, mediaProvider: MediaProviderProtocol?) { - self.init(annotation: annotation, reuseIdentifier: "\(Self.self)") - let markerView = LocationMarkerView(kind: annotation.kind, mediaProvider: mediaProvider) - let hostingController = UIHostingController(rootView: AnyView(markerView)) - self.hostingController = hostingController - let view: UIView = hostingController.view - view.backgroundColor = .clear - view.anchorPoint = .init(x: 0.5, y: 1.0) - addSubview(view) - view.bounds.size = view.intrinsicContentSize - } - - func updateContent(with kind: LocationMarkerKind, mediaProvider: MediaProviderProtocol?) { - let markerView = LocationMarkerView(kind: kind, mediaProvider: mediaProvider) - hostingController?.rootView = AnyView(markerView) - if let hostedView = hostingController?.view { - hostedView.bounds.size = hostedView.intrinsicContentSize - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError() - } + let coordinate: CLLocationCoordinate2D + let kind: LocationMarkerKind } diff --git a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift index 89cfbd9a8..32241ba83 100644 --- a/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift +++ b/ElementX/Sources/Other/MapLibre/MapLibreMapView.swift @@ -1,17 +1,41 @@ // -// Copyright 2025 Element Creations Ltd. +// Copyright 2026 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // -import Combine -import MapLibre +import Compound +import CoreLocation import MapLibreInterface +import MapLibreShim import SwiftUI -struct MapLibreMapView: UIViewRepresentable { +/// The interactive map, rendered by the `MapLibreShim` framework. +struct MapLibreMapView: View { + /// Built once, so that MapLibre's logging is only configured once. + private static let shim: MapLibreShimProtocol = { + let shim = MapLibreShim() + + shim.configureLogging { severity, message in + switch severity { + case .error: + MXLog.error(message) + case .warning: + MXLog.warning(message) + case .info: + MXLog.info(message) + case .debug: + MXLog.debug(message) + case .verbose: + MXLog.verbose(message) + } + } + + return shim + }() + struct Options { /// the final zoom level used when the first user location emit let zoomLevel: Double @@ -32,10 +56,6 @@ struct MapLibreMapView: UIViewRepresentable { } } - // MARK: - Properties - - @Environment(\.colorScheme) private var colorScheme - let mapURLBuilder: MapTilerURLBuilderProtocol let options: Options @@ -56,295 +76,33 @@ struct MapLibreMapView: UIViewRepresentable { /// Called when the user pan on the map var userDidPan: (() -> Void)? - // MARK: - UIViewRepresentable - - func makeUIView(context: Context) -> MLNMapView { - let mapView = makeMapView() - mapView.delegate = context.coordinator - setupMap(mapView: mapView, with: options) - return mapView + var body: some View { + Self.shim.makeMapView(configuration: configuration) } - func updateUIView(_ mapView: MLNMapView, context: Context) { - // Don't set the same value twice. Otherwise, if there is an error loading the map, a loop - // is caused as the `error` binding being set, which triggers this update, which sets a - // new URL, which causes another error, and so it goes on round and round in a circle. - let dynamicMapURL = mapURLBuilder.interactiveMapURL(for: .init(colorScheme)) - if mapView.styleURL != dynamicMapURL { - mapView.styleURL = dynamicMapURL - } + /// The configuration for the shim, with the app-only pieces (marker views, tint, styles) + /// resolved into the shared types it understands. + private var configuration: MapViewConfiguration { + let annotations = options.annotations + let mapURLBuilder = mapURLBuilder + let mediaProvider = mediaProvider - // If the center coordinate was updated externally (not by the map itself), move the map. - // Not applied while following a marker, where the camera is driven by the marker's position. - if showsUserLocationMode.followedMarkerID == nil, - let newCenter = mapCenterCoordinate, - newCenter != context.coordinator.lastReportedCenter { - context.coordinator.lastReportedCenter = newCenter - mapView.setCenter(newCenter, animated: true) - } - - // Update existing annotation views with fresh SwiftUI content. - // This handles the case where the annotation's view data changes after - // the annotation was initially placed (e.g. user avatar loads asynchronously). - updateAnnotations(in: mapView) - - showUserLocation(in: mapView) - - context.coordinator.updateMarkerFollowing(in: mapView, markerID: showsUserLocationMode.followedMarkerID) - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - // MARK: - Private - - private func setupMap(mapView: MLNMapView, with options: Options) { - mapView.addAnnotations(options.annotations) - mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel - mapView.centerCoordinate = options.mapCenter - } - - private func updateAnnotations(in mapView: MLNMapView) { - let existingByID = Dictionary(uniqueKeysWithValues: - (mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) }) - let updatedByID = Dictionary(uniqueKeysWithValues: options.annotations.map { ($0.id, $0) }) - - let existingIDs = Set(existingByID.keys) - let updatedIDs = Set(updatedByID.keys) - - // Remove annotations that are no longer present - let removedIDs = existingIDs.subtracting(updatedIDs) - if !removedIDs.isEmpty { - let toRemove = removedIDs.compactMap { existingByID[$0] } - mapView.removeAnnotations(toRemove) - } - - // Add new annotations - let addedIDs = updatedIDs.subtracting(existingIDs) - if !addedIDs.isEmpty { - let toAdd = addedIDs.compactMap { updatedByID[$0] } - mapView.addAnnotations(toAdd) - } - - // Update existing annotations that are still present - let keptIDs = existingIDs.intersection(updatedIDs) - for id in keptIDs { - guard let existingAnnotation = existingByID[id], - let updatedAnnotation = updatedByID[id] else { - continue - } - CoordinateAnimator.animate(annotation: existingAnnotation, - to: updatedAnnotation.coordinate, - duration: 1.0) - if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView { - annotationView.updateContent(with: updatedAnnotation.kind, mediaProvider: mediaProvider) - } - } - } - - private func makeMapView() -> MLNMapView { - let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light)) - mapView.logoViewPosition = .topLeft - mapView.attributionButtonPosition = .topLeft - mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2) - mapView.tintColor = .compound.iconAccentPrimary - mapView.allowsRotating = false - mapView.allowsTilting = false - return mapView - } - - private func showUserLocation(in mapView: MLNMapView) { - switch (showsUserLocationMode, options.annotations) { - case (.showAndFollow, _): - mapView.userTrackingMode = .follow - case (.show, let annotations) where !annotations.isEmpty: - // In the show mode, if there are annotations, we check the authorizationStatus, - // if it's not determined, we wont prompt the user with a request for permissions, - // because they should be able to see the annotations without sharing their location information. - guard mapView.locationManager.authorizationStatus != .notDetermined else { return } - fallthrough - case (.show, _): - mapView.showsUserLocation = true - mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) - case (.hide, _), (.hideAndFollowMarker, _): - // In hideAndFollowMarker mode the camera following is handled by the coordinator. - mapView.showsUserLocation = false - mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil) - } - } -} - -// MARK: - Coordinator - -extension MapLibreMapView { - class Coordinator: NSObject, MLNMapViewDelegate { - // MARK: - Properties - - var mapLibreView: MapLibreMapView - - private var previousUserLocation: MLNUserLocation? - /// Tracks the last center coordinate reported by the map (or set programmatically), - /// so that `updateUIView` can tell apart external binding changes from internal ones. - var lastReportedCenter: CLLocationCoordinate2D? - - /// The annotation the camera is currently locked on while in the `hideAndFollowMarker` mode. - private weak var followedAnnotation: LocationAnnotation? - /// Observes the followed annotation's coordinate, updated every frame by the `CoordinateAnimator`. - private var followedAnnotationObservation: NSKeyValueObservation? - - // MARK: - Setup - - init(_ mapLibreView: MapLibreMapView) { - self.mapLibreView = mapLibreView - } - - // MARK: - Marker following - - /// Keeps the camera locked on the marker with the given id, or stops following when nil. - func updateMarkerFollowing(in mapView: MLNMapView, markerID: String?) { - guard let markerID else { - stopFollowingMarker() - return - } - - guard let annotation = (mapView.annotations ?? []) - .compactMap({ $0 as? LocationAnnotation }) - .first(where: { $0.id == markerID }) else { - // The marker isn't on the map (yet), resolve it again on the next update. - stopFollowingMarker() - return - } - - guard annotation !== followedAnnotation else { return } - startFollowingMarker(annotation, in: mapView) - } - - private func startFollowingMarker(_ annotation: LocationAnnotation, in mapView: MLNMapView) { - followedAnnotation = annotation - followedAnnotationObservation = nil - - // Animate to the marker first, attaching the per-frame tracking only on completion - // so that it doesn't cancel the transition. - mapView.setCenter(annotation.coordinate, - zoomLevel: mapView.zoomLevel, - direction: -1, // negative value keeps the current direction - animated: true) { [weak self, weak annotation, weak mapView] in - guard let self, let annotation, let mapView, annotation === followedAnnotation else { return } - - // Mirror every animated coordinate update to keep the camera as smooth as the marker. - followedAnnotationObservation = annotation.observe(\.coordinate) { [weak mapView] annotation, _ in - // The coordinate is only ever updated by SwiftUI so KVO fires on the main actor. - MainActor.assumeIsolated { - mapView?.setCenter(annotation.coordinate, animated: false) - } - } - } - } - - private func stopFollowingMarker() { - followedAnnotation = nil - followedAnnotationObservation = nil - } - - // MARK: - MLNMapViewDelegate - - func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { - guard let annotation = annotation as? LocationAnnotation else { - return nil - } - return LocationAnnotationView(annotation: annotation, mediaProvider: mapLibreView.mediaProvider) - } - - func mapViewDidFailLoadingMap(_ mapView: MLNMapView, withError error: Error) { - if mapLibreView.error != .failedLoadingMap { - mapLibreView.error = .failedLoadingMap - } - } - - func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) { - guard let userLocation else { return } - mapLibreView.hasLoadedUserLocation = true - - if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true) - } - } - - previousUserLocation = userLocation - updateGeolocationUncertainty(location: userLocation) - } - - func mapView(_ mapView: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) { - switch manager.authorizationStatus { - case .denied, .restricted: - mapLibreView.isLocationAuthorized = false - case .authorizedAlways, .authorizedWhenInUse: - mapLibreView.isLocationAuthorized = true - case .notDetermined: - mapLibreView.isLocationAuthorized = nil - @unknown default: - break - } - } - - func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { - // While following a marker, don't flood the center binding with per-frame camera updates. - // Leaving `lastReportedCenter` untouched avoids an external re-centering when following stops. - guard mapLibreView.showsUserLocationMode.followedMarkerID == nil else { return } - - // Avoid `Publishing changes from within view update` warnings - DispatchQueue.main.async { [mapLibreView, weak self] in - let center = mapView.centerCoordinate - self?.lastReportedCenter = center - mapLibreView.mapCenterCoordinate = center - } - } - - func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool { - // Send userDidPan only for gestures that actually change the map center. The reason - // is an option set and gestures can come combined with other reasons, so check for - // containment instead of an exact match. - let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate] - if !reason.isDisjoint(with: centerChangingGestures) { - // Stop following immediately, the camera would fight the gesture otherwise. - stopFollowingMarker() - mapLibreView.userDidPan?() - } - return true - } - - // MARK: Callout - - func mapView(_ mapView: MLNMapView, annotationCanShowCallout annotation: MLNAnnotation) -> Bool { - false - } - - // MARK: Private - - private func updateGeolocationUncertainty(location: MLNUserLocation) { - guard let clLocation = location.location, clLocation.horizontalAccuracy >= 0 else { - mapLibreView.geolocationUncertainty = nil - return - } - - mapLibreView.geolocationUncertainty = clLocation.horizontalAccuracy - } - } -} - -// MARK: - MLNMapView convenient methods - -private extension MapTilerStyle { - init(_ colorScheme: ColorScheme) { - switch colorScheme { - case .light: - self = .light - case .dark: - self = .dark - @unknown default: - fatalError() - } + return .init(zoomLevel: options.zoomLevel, + initialZoomLevel: options.initialZoomLevel, + mapCenter: options.mapCenter, + annotations: annotations.map { .init(id: $0.id, coordinate: $0.coordinate) }, + styleURL: { mapURLBuilder.interactiveMapURL(for: $0 ? .dark : .light) }, + tintColor: .compound.iconAccentPrimary, + markerView: { annotationID in + guard let kind = annotations.first(where: { $0.id == annotationID })?.kind else { return nil } + return AnyView(LocationMarkerView(kind: kind, mediaProvider: mediaProvider)) + }, + showsUserLocationMode: $showsUserLocationMode, + error: $error, + mapCenterCoordinate: $mapCenterCoordinate, + hasLoadedUserLocation: $hasLoadedUserLocation, + isLocationAuthorized: $isLocationAuthorized, + geolocationUncertainty: $geolocationUncertainty, + userDidPan: userDidPan) } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index cd834ba40..893d11f63 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -250,6 +250,8 @@ targets: - package: Mantis - target: MapLibreInterface embed: true + - target: MapLibreShim + embed: true - package: MapLibre - package: PostHog - package: SwiftState diff --git a/project.yml b/project.yml index a031bd104..aa11c56ca 100644 --- a/project.yml +++ b/project.yml @@ -60,6 +60,7 @@ settings: include: - path: app.yml - path: ElementX/MapLibre/MapLibreInterface/SupportingFiles/target.yml +- path: ElementX/MapLibre/MapLibreShim/SupportingFiles/target.yml - path: ElementX/SupportingFiles/target.yml - path: NSE/SupportingFiles/target.yml - path: ShareExtension/SupportingFiles/target.yml