diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e06e863e08..99326b9feb 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -233,6 +233,7 @@ 2CA6ABBC9A88EB89EA52FCCB /* ConfettiScene.scn in Resources */ = {isa = PBXBuildFile; fileRef = B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */; }; 2D0E3983288E2D35613AD681 /* SecureBackupControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB29A2D95D3469B5F016655 /* SecureBackupControllerMock.swift */; }; 2D2D8A53B35BE8D8A01449C6 /* PinnedEventsBannerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */; }; + 2D38D39B1789B91AE69F477F /* PhotoLibraryManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */; }; 2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; }; 2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; }; 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; }; @@ -395,6 +396,7 @@ 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; }; 4EAC427267424192964B16B3 /* AppSettingsHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13BE9781699FB510E9263192 /* AppSettingsHook.swift */; }; + 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */; }; 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */; }; 4FDC8A9764CFDA90CE035725 /* Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2253D36E81E045E1CB432 /* Duration.swift */; }; 4FE688FE9375B2FBF424146A /* TextBasedRoomTimelineViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6EA0D8B0BBD8805F7D5A133 /* TextBasedRoomTimelineViewProtocol.swift */; }; @@ -467,6 +469,7 @@ 5F0B5797D1BFF2A51084B4C3 /* PinnedEventsTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D7CD5CA270BFC3EBB450CA /* PinnedEventsTimelineScreenViewModel.swift */; }; 5F35069E13D71DD88633A4B2 /* preview_video.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 45A4B934BA41D6C255900265 /* preview_video.jpg */; }; 5F5488FBC9CFEB6F433D74A4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7109E709A7738E6BCC4553E6 /* Localizable.strings */; }; + 5FA1DCE55973862632961D7C /* PhotoLibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */; }; 5FCD8AFA364206EE32B909A3 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = B050A6B233D95807A09289E7 /* Settings.bundle */; }; 601AB75BD52B0B4276CEB84A /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161CD412E75F4086F422AE39 /* SessionVerificationScreenStateMachine.swift */; }; 60ED66E63A169E47489348A8 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 886A0A498FA01E8EDD451D05 /* Sentry */; }; @@ -760,6 +763,7 @@ 97969EF0B9C412CD38E5CA93 /* AppLockScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4005D82E9D27BAF006A8FE1 /* AppLockScreenViewModel.swift */; }; 97BAEDD9054FB5F233EE928B /* EncryptionResetScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */; }; 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */; }; + 9826A4DBBEFA7041A9E0EFAD /* TimelineMediaPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */; }; 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */; }; 9847B056C1A216C314D21E68 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A1AB5A84D843B6AC8D5F1E /* AuthenticationService.swift */; }; 988BA75A182738150894A23F /* UserIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */; }; @@ -866,7 +870,6 @@ AE1160076F663BF14E0E893A /* EffectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4548A9BDE5CB3AB864BCA9F /* EffectsView.swift */; }; AE1A73B24D63DA3D63DC4EE3 /* SessionVerificationControllerProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */; }; AE5AAD9E32511544FDFA5560 /* WindowManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F27F588F9059128E17C669 /* WindowManagerProtocol.swift */; }; - AE69B349B0011D5EE2C13606 /* TimelineMediaPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */; }; AF19D65A9C60C6B2646F3210 /* RedactedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */; }; AF2ABA2794E376B64104C964 /* MockSoftLogoutScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5644919DB2022397D9D5825A /* MockSoftLogoutScreenState.swift */; }; AF33B9044498211C3D82F1E1 /* UNTextInputNotificationResponse+Creator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */; }; @@ -1222,7 +1225,6 @@ FA5A7E32B1920FCB4EEDC1BA /* RoomDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6493AC9979CEB1410302BFE3 /* RoomDetailsScreenCoordinator.swift */; }; FA71CD334F2D2289BEF0D749 /* SecureBackupRecoveryKeyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2FCA3D0F239B9E911B966B /* SecureBackupRecoveryKeyScreen.swift */; }; FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */; }; - FAF12EF424E55377816149DB /* MediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */; }; FB0A9D06FC9122E37992D962 /* LayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */; }; FB53CD9B74A15B3B94F9F788 /* CreateRoomModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B849D2FF2CC12BA411A1651 /* CreateRoomModels.swift */; }; FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A37E2FACFD041CE466223CD /* SceneDelegate.swift */; }; @@ -1378,7 +1380,6 @@ 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiProviderTests.swift; sourceTree = ""; }; 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenCoordinator.swift; sourceTree = ""; }; 0A459AE4B6566B2FA99E86B2 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = ""; }; - 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewView.swift; sourceTree = ""; }; 0B0E0B55E2EE75AF67029924 /* SwipeToReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyView.swift; sourceTree = ""; }; 0B32BBA8887BD7A5C4ECF16F /* RoomModerationRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModerationRole.swift; sourceTree = ""; }; 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineItem.swift; sourceTree = ""; }; @@ -1390,7 +1391,6 @@ 0CB569EAA5017B5B23970655 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = ""; }; 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = ""; }; - 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileManager.swift; sourceTree = ""; }; 0D879FC4E881E748BB9B34DC /* RoomChangePermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangePermissionsScreenCoordinator.swift; sourceTree = ""; }; 0D8F620C8B314840D8602E3F /* NSE.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NSE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0DBB08A95EFA668F2CF27211 /* AppLockSetupFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupFlowCoordinator.swift; sourceTree = ""; }; @@ -1569,6 +1569,7 @@ 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = ""; }; 306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenModels.swift; sourceTree = ""; }; 307702DD66E7DDCDD9214784 /* IdentityConfirmedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmedScreen.swift; sourceTree = ""; }; + 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewFileExportPicker.swift; sourceTree = ""; }; 309AD8BAE6437C31BA7157BF /* ElementCallWidgetDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriver.swift; sourceTree = ""; }; 30ED584467DB380E3CEFB1DB /* NotificationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerTests.swift; sourceTree = ""; }; 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceipt.swift; sourceTree = ""; }; @@ -2115,6 +2116,7 @@ A9E88667D393612FD5D84718 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/SAS.strings; sourceTree = ""; }; A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; AA19C32BD97F45847724E09A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Untranslated.strings; sourceTree = ""; }; + AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewScreen.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 = ""; }; @@ -2362,6 +2364,7 @@ DCA2D836BD10303F37FAAEED /* test_voice_message.m4a */ = {isa = PBXFileReference; path = test_voice_message.m4a; sourceTree = ""; }; DCAC01A97A43BE07B9E94E43 /* ShareExtensionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionModels.swift; sourceTree = ""; }; DCF239C619971FDE48132550 /* SecureBackupLogoutConfirmationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenModels.swift; sourceTree = ""; }; + DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryManagerMock.swift; sourceTree = ""; }; DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockServiceTests.swift; sourceTree = ""; }; DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRoomScreenViewModelTests.swift; sourceTree = ""; }; DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileHandleProxy.swift; sourceTree = ""; }; @@ -2408,6 +2411,7 @@ E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; E6F5D66F158A6662F953733E /* NotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxy.swift; sourceTree = ""; }; E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = ""; }; + E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryManager.swift; sourceTree = ""; }; E76A706B3EEA32B882DA5E2D /* BlockedUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModelProtocol.swift; sourceTree = ""; }; E78FC546F28E045A560F2963 /* EncryptionKeyProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyProviderProtocol.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; @@ -3171,6 +3175,7 @@ 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, B2AD8A56CD37E23071A2F4BF /* PHGPostHogMock.swift */, + DD955A0380C287C418F1A74D /* PhotoLibraryManagerMock.swift */, D38391154120264910D19528 /* PollMock.swift */, 894EE8F5B399A165BA2A6634 /* RoomDirectorySearchMock.swift */, 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */, @@ -3492,10 +3497,9 @@ isa = PBXGroup; children = ( 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */, - 0D3615FD6460BC9C1DEF8659 /* MediaFileManager.swift */, + E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */, E3A62FBD3007312311C14DD8 /* TimelineMediaPreviewCoordinator.swift */, 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */, - 0A76B92984638A9B3104840D /* TimelineMediaPreviewView.swift */, 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */, 5EC4A8482DA110602FE6DF42 /* View */, ); @@ -3889,7 +3893,9 @@ isa = PBXGroup; children = ( 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */, + 30856520F3263D0E195710D7 /* TimelineMediaPreviewFileExportPicker.swift */, C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */, + AA57E8563C346B13DDE4A6F4 /* TimelineMediaPreviewScreen.swift */, ); path = View; sourceTree = ""; @@ -7095,7 +7101,6 @@ C11D4A49DC29D89CE2BB31B8 /* MediaEventsTimelineScreenViewModel.swift in Sources */, FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */, BCC864190651B3A3CF51E4DF /* MediaFileHandleProxy.swift in Sources */, - FAF12EF424E55377816149DB /* MediaFileManager.swift in Sources */, 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */, A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */, 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */, @@ -7173,6 +7178,8 @@ 847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */, 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */, BC7CA1379D7C24F47B1B8B7E /* PaginationIndicatorRoomTimelineView.swift in Sources */, + 5FA1DCE55973862632961D7C /* PhotoLibraryManager.swift in Sources */, + 2D38D39B1789B91AE69F477F /* PhotoLibraryManagerMock.swift in Sources */, 962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */, EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */, 899359A4D1147601F6C4E364 /* PillConstants.swift in Sources */, @@ -7491,9 +7498,10 @@ 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */, FE43747C116CA3D8D6B92F5F /* TimelineMediaPreviewCoordinator.swift in Sources */, 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */, + 4ED764A24F2A715C25CF07F1 /* TimelineMediaPreviewFileExportPicker.swift in Sources */, 77FB08C303F4C74C0E8577E2 /* TimelineMediaPreviewModels.swift in Sources */, A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */, - AE69B349B0011D5EE2C13606 /* TimelineMediaPreviewView.swift in Sources */, + 9826A4DBBEFA7041A9E0EFAD /* TimelineMediaPreviewScreen.swift in Sources */, 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */, B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */, E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */, @@ -8539,7 +8547,7 @@ repositoryURL = "https://github.com/element-hq/compound-ios"; requirement = { kind = revision; - revision = 1a70bc7f3420647843b9c18748982c61ef7d2245; + revision = 9325643cb4d22150881c5bf79e1e6e3c5a87ea89; }; }; F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0d566b988b..8f92300dc3 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-design-tokens", "state" : { - "revision" : "f79e05011ec3402c29ded19bcff95b5ead180991", - "version" : "2.1.2" + "revision" : "a6e96fb4436a4945423a8c068001093af4b7b315", + "version" : "3.0.1" } }, { @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-ios", "state" : { - "revision" : "1a70bc7f3420647843b9c18748982c61ef7d2245" + "revision" : "9325643cb4d22150881c5bf79e1e6e3c5a87ea89" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings index 47f84ab950..8c69574587 100644 --- a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings +++ b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings @@ -2,4 +2,4 @@ "NSFaceIDUsageDescription" = "Face ID is used to access your app."; "NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location."; "NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone."; -"NSPhotoLibraryUsageDescription" = "Allows saving photos and videos to your library."; +"NSPhotoLibraryUsageDescription" = "This lets you save images and videos to your photo library."; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index adbfd58391..8fd3c11660 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -274,6 +274,7 @@ "dialog_permission_microphone_description_ios" = "Grant access so you can record and send messages with audio."; "dialog_permission_microphone_title_ios" = "%1$@ needs permission to access your microphone."; "dialog_permission_notification" = "In order to let the application display notifications, please grant the permission in the system settings."; +"dialog_permission_photo_library_title_ios" = "%1$@ does not have access to your photo library."; "dialog_title_confirmation" = "Confirmation"; "dialog_title_warning" = "Warning"; "dialog_unsaved_changes_description_ios" = "Your changes won’t be saved"; diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index 1d2878d98d..a08af5a6a6 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -109,7 +109,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) { let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext, mediaProvider: userSession.mediaProvider, - userIndicatorController: userIndicatorController) + userIndicatorController: userIndicatorController, + appMediator: appMediator) let coordinator = TimelineMediaPreviewCoordinator(parameters: parameters) coordinator.actionsPublisher diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 046a5b8963..ef72933439 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -606,6 +606,10 @@ internal enum L10n { } /// In order to let the application display notifications, please grant the permission in the system settings. internal static var dialogPermissionNotification: String { return L10n.tr("Localizable", "dialog_permission_notification") } + /// %1$@ does not have access to your photo library. + internal static func dialogPermissionPhotoLibraryTitleIos(_ p1: Any) -> String { + return L10n.tr("Localizable", "dialog_permission_photo_library_title_ios", String(describing: p1)) + } /// Confirmation internal static var dialogTitleConfirmation: String { return L10n.tr("Localizable", "dialog_title_confirmation") } /// Error diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 1a8aa5b5ab..46b42ecb74 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8,6 +8,7 @@ import Combine import Foundation import LocalAuthentication import MatrixRustSDK +import Photos import SwiftUI class AnalyticsClientMock: AnalyticsClientProtocol { var isRunning: Bool { @@ -12327,6 +12328,79 @@ class PHGPostHogMock: PHGPostHogProtocol { screenPropertiesClosure?(screenTitle, properties) } } +class PhotoLibraryManagerMock: PhotoLibraryManagerProtocol { + + //MARK: - addResource + + var addResourceAtUnderlyingCallsCount = 0 + var addResourceAtCallsCount: Int { + get { + if Thread.isMainThread { + return addResourceAtUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = addResourceAtUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + addResourceAtUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + addResourceAtUnderlyingCallsCount = newValue + } + } + } + } + var addResourceAtCalled: Bool { + return addResourceAtCallsCount > 0 + } + var addResourceAtReceivedArguments: (type: PHAssetResourceType, url: URL)? + var addResourceAtReceivedInvocations: [(type: PHAssetResourceType, url: URL)] = [] + + var addResourceAtUnderlyingReturnValue: Result! + var addResourceAtReturnValue: Result! { + get { + if Thread.isMainThread { + return addResourceAtUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = addResourceAtUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + addResourceAtUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + addResourceAtUnderlyingReturnValue = newValue + } + } + } + } + var addResourceAtClosure: ((PHAssetResourceType, URL) async -> Result)? + + func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result { + addResourceAtCallsCount += 1 + addResourceAtReceivedArguments = (type: type, url: url) + DispatchQueue.main.async { + self.addResourceAtReceivedInvocations.append((type: type, url: url)) + } + if let addResourceAtClosure = addResourceAtClosure { + return await addResourceAtClosure(type, url) + } else { + return addResourceAtReturnValue + } + } +} class PollInteractionHandlerMock: PollInteractionHandlerProtocol { //MARK: - sendPollResponse diff --git a/ElementX/Sources/Mocks/PhotoLibraryManagerMock.swift b/ElementX/Sources/Mocks/PhotoLibraryManagerMock.swift new file mode 100644 index 0000000000..dc8046a1f2 --- /dev/null +++ b/ElementX/Sources/Mocks/PhotoLibraryManagerMock.swift @@ -0,0 +1,21 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Foundation + +extension PhotoLibraryManagerMock { + struct Configuration { + var authorizationDenied = false + } + + // swiftlint:disable:next cyclomatic_complexity + convenience init(_ configuration: Configuration) { + self.init() + + addResourceAtReturnValue = configuration.authorizationDenied ? .failure(PhotoLibraryManagerError.notAuthorized) : .success(()) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift b/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift deleted file mode 100644 index bad960796f..0000000000 --- a/ElementX/Sources/Screens/FilePreviewScreen/MediaFileManager.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Copyright 2024 New Vector Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. -// - -import Foundation diff --git a/ElementX/Sources/Screens/FilePreviewScreen/PhotoLibraryManager.swift b/ElementX/Sources/Screens/FilePreviewScreen/PhotoLibraryManager.swift new file mode 100644 index 0000000000..d4b37015dc --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/PhotoLibraryManager.swift @@ -0,0 +1,37 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Photos + +enum PhotoLibraryManagerError: Error { + case notAuthorized + case unknown(Error) +} + +// sourcery: AutoMockable +protocol PhotoLibraryManagerProtocol { + func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result +} + +struct PhotoLibraryManager: PhotoLibraryManagerProtocol { + func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result { + do { + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + let options = PHAssetResourceCreationOptions() + request.addResource(with: type, fileURL: url, options: options) + } + return .success(()) + } catch { + if (error as NSError).code == PHPhotosError.accessUserDenied.rawValue { + return .failure(.notAuthorized) + } else { + return .failure(.unknown(error)) + } + } + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift index 2575108dbe..5f937f1805 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift @@ -27,6 +27,7 @@ struct TimelineMediaPreviewCoordinatorParameters { let context: TimelineMediaPreviewContext let mediaProvider: MediaProviderProtocol let userIndicatorController: UserIndicatorControllerProtocol + let appMediator: AppMediatorProtocol } enum TimelineMediaPreviewCoordinatorAction { @@ -50,7 +51,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol { viewModel = TimelineMediaPreviewViewModel(context: parameters.context, mediaProvider: parameters.mediaProvider, - userIndicatorController: parameters.userIndicatorController) + photoLibraryManager: PhotoLibraryManager(), + userIndicatorController: parameters.userIndicatorController, + appMediator: parameters.appMediator) } func start() { @@ -69,6 +72,6 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol { } func toPresentable() -> AnyView { - AnyView(TimelineMediaPreviewView(context: viewModel.context)) + AnyView(TimelineMediaPreviewScreen(context: viewModel.context)) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index b1c790eb79..0172fddc72 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -26,8 +26,18 @@ struct TimelineMediaPreviewViewState: BindableState { } struct TimelineMediaPreviewViewStateBindings { + /// A binding that will present the Details view for the specified item. var mediaDetailsItem: TimelineMediaPreviewItem? + /// A binding that will present a confirmation to redact the specified item. var redactConfirmationItem: TimelineMediaPreviewItem? + /// A binding that will present a document picker to export the specified file. + var fileToExport: TimelineMediaPreviewFileExportPicker.File? + + var alertInfo: AlertInfo? +} + +enum TimelineMediaPreviewAlertType { + case authorizationRequired } /// Wraps a media file and title to be previewed with QuickLook. diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index c0079164fd..e3cdc1c704 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -13,7 +13,9 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel = .init() var actions: AnyPublisher { @@ -22,12 +24,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { init(context: TimelineMediaPreviewContext, mediaProvider: MediaProviderProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + photoLibraryManager: PhotoLibraryManagerProtocol, + userIndicatorController: UserIndicatorControllerProtocol, + appMediator: AppMediatorProtocol) { timelineViewModel = context.viewModel self.mediaProvider = mediaProvider - - // We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔 + self.photoLibraryManager = photoLibraryManager self.userIndicatorController = userIndicatorController + self.appMediator = appMediator let currentItem = TimelineMediaPreviewItem(timelineItem: context.item) @@ -64,10 +68,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { MXLog.error("Received unexpected action: \(action)") } case .redactConfirmation(let item): - timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) - state.bindings.redactConfirmationItem = nil - state.bindings.mediaDetailsItem = nil - actionsSubject.send(.dismiss) + redactItem(item) case .dismiss: actionsSubject.send(.dismiss) } @@ -108,12 +109,43 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } private func saveCurrentItem() async { - guard let url = state.currentItem.fileHandle?.url else { + guard let fileURL = state.currentItem.fileHandle?.url else { MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.") return } - showErrorIndicator() + do { + switch state.currentItem.timelineItem { + case is AudioRoomTimelineItem, is FileRoomTimelineItem: + state.bindings.fileToExport = .init(url: fileURL) + return // Don't show the indicator. + case is ImageRoomTimelineItem: + try await photoLibraryManager.addResource(.photo, at: fileURL).get() + case is VideoRoomTimelineItem: + try await photoLibraryManager.addResource(.video, at: fileURL).get() + default: + break + } + + showSavedIndicator() + } catch PhotoLibraryManagerError.notAuthorized { + MXLog.error("Not authorised to save item to photo library") + state.bindings.alertInfo = .init(id: .authorizationRequired, + title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName), + primaryButton: .init(title: L10n.commonSettings) { self.appMediator.openAppSettings() }, + secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) + } catch { + MXLog.error("Failed saving item: \(error)") + showErrorIndicator() + } + } + + private func redactItem(_ item: TimelineMediaPreviewItem) { + timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) + state.bindings.redactConfirmationItem = nil + state.bindings.mediaDetailsItem = nil + actionsSubject.send(.dismiss) + showRedactedIndicator() } // MARK: - Indicators @@ -132,22 +164,38 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { userIndicatorController.retractIndicatorWithId(indicatorID) } + // FIXME: Add the strings and correct indicator types private func showDownloadErrorIndicator() { - // FIXME: Add the correct string and indicator type?? userIndicatorController.submitIndicator(UserIndicator(id: downloadErrorIndicatorID, type: .modal, title: L10n.errorUnknown, iconName: "exclamationmark.circle.fill")) } + private func showRedactedIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID, + type: .toast, + title: "File deleted", + iconName: "checkmark")) + } + + private func showSavedIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID, + type: .toast, + title: "File saved", + iconName: "checkmark")) + } + private func showErrorIndicator() { - userIndicatorController.submitIndicator(UserIndicator(id: errorIndicatorID, - type: .modal, + userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID, + type: .toast, title: L10n.errorUnknown, iconName: "xmark")) } - private var errorIndicatorID: String { "\(Self.self)-Error" } + private var statusIndicatorID: String { "\(Self.self)-Status" } + + // Separate indicator IDs for downloads as these can be triggered in the background when swiping between items private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" } private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String { "\(Self.self)-Download-\(itemID.uniqueID.id)" diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index 7b25c8f388..f1e3c27702 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -184,6 +184,8 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie viewModel: TimelineViewModel.mock(timelineKind: timelineKind), namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + photoLibraryManager: PhotoLibraryManagerMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewFileExportPicker.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewFileExportPicker.swift new file mode 100644 index 0000000000..134c8aea25 --- /dev/null +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewFileExportPicker.swift @@ -0,0 +1,23 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +struct TimelineMediaPreviewFileExportPicker: UIViewControllerRepresentable { + struct File: Identifiable { + let url: URL + var id: String { url.absoluteString } + } + + let file: File + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + UIDocumentPickerViewController(forExporting: [file.url], asCopy: true) + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index 07b20988ce..ca62a953bb 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -142,6 +142,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes viewModel: TimelineViewModel.mock, namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + photoLibraryManager: PhotoLibraryManagerMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift similarity index 93% rename from ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift rename to ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift index 55ab2be85b..aaac9d7e36 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift @@ -10,7 +10,7 @@ import Compound import QuickLook import SwiftUI -struct TimelineMediaPreviewView: View { +struct TimelineMediaPreviewScreen: View { @ObservedObject var context: TimelineMediaPreviewViewModel.Context private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } @@ -33,6 +33,11 @@ struct TimelineMediaPreviewView: View { .sheet(item: $context.mediaDetailsItem) { item in TimelineMediaPreviewDetailsView(item: item, context: context) } + .sheet(item: $context.fileToExport) { file in + TimelineMediaPreviewFileExportPicker(file: file) + .preferredColorScheme(.dark) + } + .alert(item: $context.alertInfo) .preferredColorScheme(.dark) .zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace) } @@ -102,7 +107,7 @@ struct TimelineMediaPreviewView: View { Spacer() Button { context.send(viewAction: .saveCurrentItem) } label: { - CompoundIcon(\.download) + CompoundIcon(\.downloadIos) } } } @@ -179,13 +184,13 @@ private struct QuickLookView: UIViewControllerRepresentable { // MARK: - Previews -struct TimelineMediaPreviewView_Previews: PreviewProvider { +struct TimelineMediaPreviewScreen_Previews: PreviewProvider { @Namespace private static var namespace static let viewModel = makeViewModel() static var previews: some View { - QuickLookView(viewModelContext: viewModel.context) + TimelineMediaPreviewScreen(context: viewModel.context) } static func makeViewModel() -> TimelineMediaPreviewViewModel { @@ -207,6 +212,8 @@ struct TimelineMediaPreviewView_Previews: PreviewProvider { viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)), namespace: namespace), mediaProvider: MediaProviderMock(configuration: .init()), - userIndicatorController: UserIndicatorControllerMock()) + photoLibraryManager: PhotoLibraryManagerMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) } } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallInviteRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallInviteRoomTimelineView.swift index a4cb8af360..633d605b8d 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallInviteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallInviteRoomTimelineView.swift @@ -16,7 +16,7 @@ struct CallInviteRoomTimelineView: View { Label { Text(L10n.screenRoomTimelineLegacyCall) } icon: { - CompoundIcon(\.voiceCall, size: .medium, relativeTo: .compound.bodyMD) + CompoundIcon(\.voiceCallSolid, size: .medium, relativeTo: .compound.bodyMD) } .font(.compound.bodyMD) .foregroundColor(.compound.textSecondary) diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index 39c81a8d9c..1008fa9c4e 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -73,7 +73,7 @@ NSMicrophoneUsageDescription To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone. NSPhotoLibraryAddUsageDescription - Allows saving photos and videos to your library. + This lets you save images and videos to your photo library. NSUserActivityTypes INSendMessageIntent diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index bb92ccae3d..1306c5c91d 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -87,7 +87,7 @@ targets: ] NSCameraUsageDescription: To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera. NSMicrophoneUsageDescription: To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone. - NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library. + NSPhotoLibraryAddUsageDescription: This lets you save images and videos to your photo library. NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location. NSFaceIDUsageDescription: Face ID is used to access your app. UIBackgroundModes: [ diff --git a/Tools/Sourcery/AutoMockableConfig.yml b/Tools/Sourcery/AutoMockableConfig.yml index f1cc89371a..fb81d99399 100644 --- a/Tools/Sourcery/AutoMockableConfig.yml +++ b/Tools/Sourcery/AutoMockableConfig.yml @@ -9,4 +9,4 @@ output: ../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift args: automMockableTestableImports: [] - autoMockableImports: [AnalyticsEvents, AVFoundation, Combine, Foundation, LocalAuthentication, MatrixRustSDK, SwiftUI] + autoMockableImports: [AnalyticsEvents, AVFoundation, Combine, Foundation, LocalAuthentication, MatrixRustSDK, Photos, SwiftUI] diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 53be6df5c6..56cc8ed1a4 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -17,6 +17,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { var viewModel: TimelineMediaPreviewViewModel! var context: TimelineMediaPreviewViewModel.Context { viewModel.context } var mediaProvider: MediaProviderMock! + var photoLibraryManager: PhotoLibraryManagerMock! var timelineController: MockRoomTimelineController! func testLoadingItem() async throws { @@ -27,9 +28,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertNotNil(context.viewState.currentItemActions) // When the preview controller sets the current item. - let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0])) - try await deferred.fulfill() + try await loadInitialItem() // Then the view model should load the item and update its view state. XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) @@ -82,6 +81,73 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertTrue(timelineController.redactCalled) } + func testSaveImage() async throws { + // Given a view model with a loaded image. + try await testLoadingItem() + XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") + + // When choosing to save the image. + let item = context.viewState.currentItem + context.send(viewAction: .saveCurrentItem) + try await Task.sleep(for: .seconds(0.5)) + + // Then the image should be saved as a photo to the user's photo library. + XCTAssertTrue(photoLibraryManager.addResourceAtCalled) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url) + } + + func testSaveImageWithoutAuthorization() async throws { + // Given a view model with a loaded image where the user has denied access to the photo library. + setupViewModel(photoLibraryAuthorizationDenied: true) + try await loadInitialItem() + XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") + + // When choosing to save the image. + let item = context.viewState.currentItem + let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } + context.send(viewAction: .saveCurrentItem) + try await deferred.fulfill() + + // Then the user should be prompted to allow access. + XCTAssertTrue(photoLibraryManager.addResourceAtCalled) + XCTAssertEqual(context.alertInfo?.id, .authorizationRequired) + } + + func testSaveVideo() async throws { + // Given a view model with a loaded video. + setupViewModel(initialItemIndex: 1) + try await loadInitialItem() + XCTAssertEqual(viewModel.state.currentItem.contentType, "MPEG-4 movie") + + // When choosing to save the video. + let item = context.viewState.currentItem + context.send(viewAction: .saveCurrentItem) + try await Task.sleep(for: .seconds(0.5)) + + // Then the video should be saved as a video in the user's photo library. + XCTAssertTrue(photoLibraryManager.addResourceAtCalled) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video) + XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url) + } + + func testSaveFile() async throws { + // Given a view model with a loaded file. + setupViewModel(initialItemIndex: 2) + try await loadInitialItem() + XCTAssertEqual(viewModel.state.currentItem.contentType, "PDF document") + + // When choosing to save the file. + let item = context.viewState.currentItem + context.send(viewAction: .saveCurrentItem) + try await Task.sleep(for: .seconds(0.5)) + + // Then the binding should be set for the user to export the file to their specified location. + XCTAssertFalse(photoLibraryManager.addResourceAtCalled) + XCTAssertNotNil(context.fileToExport) + XCTAssertEqual(context.fileToExport?.url, item.fileHandle?.url) + } + func testDismiss() async throws { // Given a view model with a loaded item. try await testLoadingItem() @@ -96,30 +162,66 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { // MARK: - Helpers + private func loadInitialItem() async throws { + let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } + context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0])) + try await deferred.fulfill() + } + @Namespace private var testNamespace - private func setupViewModel() { - let item = ImageRoomTimelineItem(id: .randomEvent, - timestamp: .mock, - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: false, - sender: .init(id: "", displayName: "Sally Sanderson"), - content: .init(filename: "Amazing image.jpeg", - caption: "A caption goes right here.", - imageInfo: .mockImage, - thumbnailInfo: .mockThumbnail)) - + private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) { timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) - timelineController.timelineItems = [item] + timelineController.timelineItems = items mediaProvider = MediaProviderMock(configuration: .init()) - viewModel = TimelineMediaPreviewViewModel(context: .init(item: item, + photoLibraryManager = PhotoLibraryManagerMock(.init(authorizationDenied: photoLibraryAuthorizationDenied)) + + viewModel = TimelineMediaPreviewViewModel(context: .init(item: items[initialItemIndex], viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen), timelineController: timelineController), namespace: testNamespace), mediaProvider: mediaProvider, - userIndicatorController: UserIndicatorControllerMock()) + photoLibraryManager: photoLibraryManager, + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock()) } + + private let items: [EventBasedMessageTimelineItemProtocol] = [ + ImageRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: "", displayName: "Sally Sanderson"), + content: .init(filename: "Amazing image.jpeg", + caption: "A caption goes right here.", + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + contentType: .jpeg)), + VideoRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(filename: "Super video.mp4", + videoInfo: .mockVideo, + thumbnailInfo: .mockThumbnail, + contentType: .mpeg4Movie)), + FileRoomTimelineItem(id: .randomEvent, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + isThreaded: false, + sender: .init(id: ""), + content: .init(filename: "Important file.pdf", + source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"), + fileSize: 2453, + thumbnailSource: nil, + contentType: .pdf)) + ] } diff --git a/project.yml b/project.yml index 9713a8248d..77ce8d8c84 100644 --- a/project.yml +++ b/project.yml @@ -65,7 +65,7 @@ packages: # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios - revision: 1a70bc7f3420647843b9c18748982c61ef7d2245 + revision: 9325643cb4d22150881c5bf79e1e6e3c5a87ea89 # path: ../compound-ios AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events