From e28a4043ee4ccb56f4d9c40e859d7e816cd20997 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Mon, 7 Sep 2026 19:40:47 +0700 Subject: [PATCH] some fixes --- Luma.xcodeproj/project.pbxproj | 20 + Scripts/verify.sh | 12 + Sources/Shared/Models/AppModel.swift | 56 ++- .../Models/ArchiveMessageBatchPolicy.swift | 4 +- .../Models/OlderHistoryScrollPolicy.swift | 30 ++ .../Models/VideoNoteRotationPolicy.swift | 47 ++ .../Shared/Services/VideoNoteRecorder.swift | 466 +++++++++++++++--- Sources/Shared/UI/ChatView.swift | 99 +++- Sources/Shared/UI/VideoNoteCaptureView.swift | 34 ++ Sources/Shared/XMPP/XMPPService.swift | 48 +- Tests/ArchiveMessageBatchPolicyTests.swift | 5 +- Tests/OlderHistoryScrollPolicyTests.swift | 47 ++ Tests/VideoNoteRotationPolicyTests.swift | 44 ++ 13 files changed, 793 insertions(+), 119 deletions(-) create mode 100644 Sources/Shared/Models/OlderHistoryScrollPolicy.swift create mode 100644 Sources/Shared/Models/VideoNoteRotationPolicy.swift create mode 100644 Tests/OlderHistoryScrollPolicyTests.swift create mode 100644 Tests/VideoNoteRotationPolicyTests.swift diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 1112b1e..cf3281d 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; }; 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; }; + 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; }; 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; 087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; }; @@ -69,6 +70,7 @@ 3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; }; 32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; + 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; }; 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; }; 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; @@ -90,6 +92,7 @@ 481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */; }; 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; }; 48BE02EB8AA84E9CD1354E1A /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; + 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */; }; 496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; 49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; 4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; @@ -184,11 +187,14 @@ A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; }; A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; }; A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */; }; + A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */; }; + A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; }; A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */; }; A90142509385DE6E83818953 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; }; ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; }; AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; }; ADB2CCC69CA91FA820B3A4CA /* MessageReactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92CDC8D3E0DA178D0E3F52 /* MessageReactionTests.swift */; }; + AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; }; AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; }; B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; }; @@ -370,6 +376,7 @@ 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; 43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; }; + 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicyTests.swift; sourceTree = ""; }; 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = ""; }; 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; }; 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; }; @@ -377,6 +384,7 @@ 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; }; 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = ""; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; }; + 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicy.swift; sourceTree = ""; }; 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = ""; }; 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = ""; }; 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = ""; }; @@ -427,6 +435,7 @@ A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = ""; }; A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaImageCache.swift; sourceTree = ""; }; A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSendActivityTracker.swift; sourceTree = ""; }; + A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicyTests.swift; sourceTree = ""; }; A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = ""; }; A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = ""; }; A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = ""; }; @@ -452,6 +461,7 @@ C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = ""; }; C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = ""; }; C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = ""; }; + C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicy.swift; sourceTree = ""; }; CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = ""; }; CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = ""; }; CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = ""; }; @@ -561,11 +571,13 @@ 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */, 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */, 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */, + 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */, A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */, 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */, E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */, E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */, 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */, + C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */, ); path = Models; sourceTree = ""; @@ -661,6 +673,7 @@ BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */, 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */, F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, + A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */, 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, @@ -669,6 +682,7 @@ 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */, + 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */, 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */, 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */, ); @@ -1075,6 +1089,7 @@ D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */, 66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */, 7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */, + A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */, 6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */, C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */, 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */, @@ -1096,6 +1111,7 @@ 01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */, 275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */, 1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */, + 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */, F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1196,6 +1212,7 @@ B91AA414D00C6B54BE32ED25 /* NotificationCoordinator.swift in Sources */, D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */, 097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */, + AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */, FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */, 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */, 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */, @@ -1217,6 +1234,7 @@ 1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */, B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */, CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */, + 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */, FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1258,6 +1276,7 @@ 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */, 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, + A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */, 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */, @@ -1266,6 +1285,7 @@ 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */, E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */, + 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */, 89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */, 7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */, ); diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 1ac3142..667e471 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -18,12 +18,14 @@ required=( Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift Sources/Shared/Models/ArchiveSyncWorkBudget.swift Sources/Shared/Models/ArchiveMessageBatchPolicy.swift + Sources/Shared/Models/OlderHistoryScrollPolicy.swift Sources/Shared/Models/ChatScrollPositionPolicy.swift Sources/Shared/Models/ChatTimelineEntry.swift Sources/Shared/Models/ChatTypingPolicy.swift Sources/Shared/Models/MessageReaction.swift Sources/Shared/Models/MediaPickerSelectionPolicy.swift Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift + Sources/Shared/Models/VideoNoteRotationPolicy.swift Sources/Shared/Models/VideoNoteRecordingLifecycle.swift Sources/Shared/Models/MessageReplySwipePolicy.swift Sources/Shared/Models/MessageReplyFallback.swift @@ -66,6 +68,7 @@ required=( Tests/MediaViewerDismissGestureTests.swift Tests/ArchiveSyncCheckpointTests.swift Tests/ArchiveSyncPaginationTests.swift + Tests/OlderHistoryScrollPolicyTests.swift Tests/ArchiveSyncRecoveryPolicyTests.swift Tests/ArchiveSyncWorkBudgetTests.swift Tests/ArchiveMessageBatchPolicyTests.swift @@ -77,6 +80,7 @@ required=( Tests/MediaPickerSelectionPolicyTests.swift Tests/VideoNoteRecordingCompletionPolicyTests.swift Tests/VideoNoteRecordingLifecycleTests.swift + Tests/VideoNoteRotationPolicyTests.swift Tests/VideoNoteStopPolicyTests.swift Tests/MessageReplySwipeTests.swift Tests/WatchVoiceMessageTests.swift @@ -246,6 +250,10 @@ grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService. echo "Incremental MAM passes must have a finite foreground work budget" exit 1 } +grep -q 'OlderHistoryScrollPolicy.page(' Sources/Shared/XMPP/XMPPService.swift || { + echo "Interactive history paging must advance via the server's RSM first cursor" + exit 1 +} if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then echo "Failed MAM queries must not restart themselves indefinitely" exit 1 @@ -642,6 +650,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video' echo "Video-note recording must not reuse the inline voice-message gesture" exit 1 fi +grep -q 'VideoNoteRotationPolicy.currentInterfaceOrientation' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Video note capture must rotate by the interface orientation, not the device sensor" + exit 1 +} grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || { echo "Reusable video-note capture cleanup is missing" exit 1 diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index 7f1ff02..009753d 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -121,6 +121,11 @@ final class AppModel: ObservableObject { private var lastSuccessfulMAMCursor: String? private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] private var hasMoreOlderHistoryByConversation: [String: Bool] = [:] + /// Server-side cursor (RSM of the last loaded page) for the next + /// interactive backward-history request, per conversation. The cursor + /// advances even when a page inserted nothing (reactions, duplicates), so + /// scrolling always moves toward genuinely older history. + private var olderHistoryCursorByConversation: [String: String] = [:] private var pendingRoomPasswords: [String: String] = [:] private var joiningRoomJIDs: Set = [] private var deletedGroupChatJIDs: Set = [] @@ -737,12 +742,13 @@ final class AppModel: ObservableObject { func selectConversation(id: String) { let normalized = id.lowercased() selectedConversationID = normalized -// if let index = conversations.firstIndex(where: { $0.jid == normalized }) { -// conversations[index].unreadCount = 0 -// } -// schedulePersist() -// syncWatch() -// hasMoreOlderHistory = true + // Opening the chat marks it as read, so the unread badge disappears + // immediately and stays in sync with the watch. + if let index = conversations.firstIndex(where: { $0.jid == normalized }) { + conversations[index].unreadCount = 0 + } + schedulePersist() + syncWatch() if hasMoreOlderHistoryByConversation[normalized] == false, selectedMessages.isEmpty { @@ -753,6 +759,17 @@ final class AppModel: ObservableObject { } hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true } + + /// Called when the chat view disappears (back to the list, another chat + /// selected, or a mode switch). Without this, `selectedConversationID` + /// would keep pointing at the last viewed chat forever, and incoming + /// messages for it would never increment the unread badge again. + func endConversationViewing(jid: String) { + let normalized = jid.lowercased() + if selectedConversationID == normalized { + selectedConversationID = nil + } + } func loadOlderHistoryForSelectedConversation() { // While disconnected the request would only fail with @@ -780,21 +797,37 @@ final class AppModel: ObservableObject { isLoadingOlderHistory = true let conversationID = conversation.jid.lowercased() + // Prefer the cursor the server returned with the previous page: it + // always points at genuinely older history. Fall back to the oldest + // local stanza id only when no server cursor is known yet (freshly + // opened conversation in this session). + let before = + olderHistoryCursorByConversation[conversationID] + ?? oldestServerID xmpp.loadOlderHistory( conversationJID: conversation.jid, isGroup: conversation.isGroup, - before: oldestServerID + before: before ) { [weak self] result in guard let self else { return } self.isLoadingOlderHistory = false switch result { - case .success(let hasMore): -// self.hasMoreOlderHistory = hasMore - self.hasMoreOlderHistoryByConversation[conversationID] = hasMore + case .success(let page): + if let nextBefore = page.nextBefore { + self.olderHistoryCursorByConversation[conversationID] = + nextBefore + } else { + // Exhausted or the server stopped echoing RSM: never + // re-request the same page in a loop. + self.olderHistoryCursorByConversation[conversationID] = nil + } + self.hasMoreOlderHistoryByConversation[conversationID] = + page.hasMore if self.selectedConversationID == conversationID { - self.hasMoreOlderHistory = hasMore + self.hasMoreOlderHistory = page.hasMore } case .failure(let error): + // Keep the cursor: the next scroll retries the same page. self.errorMessage = error.localizedDescription } } @@ -935,6 +968,7 @@ final class AppModel: ObservableObject { pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) } pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") } hasMoreOlderHistoryByConversation.removeValue(forKey: normalized) + olderHistoryCursorByConversation.removeValue(forKey: normalized) pendingRoomPasswords.removeValue(forKey: normalized) joiningRoomJIDs.remove(normalized) localChatStateByConversation.removeValue(forKey: normalized) diff --git a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift index 5aa4a38..2493b68 100644 --- a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift +++ b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift @@ -4,14 +4,14 @@ import Foundation /// still published atomically; these values only bound network and decryption /// work performed before that publication. enum ArchiveMessageBatchPolicy { - static let pageSize = 24 + static let pageSize = 40 static let bootstrapMessageLimit = 40 /// Decrypt this many archived stanzas per main-actor slice before yielding. /// One-by-one decryption turns a large MAM catch-up into hours of serialized /// work; a small batch keeps frames mostly intact while cutting the number /// of run-loop hand-offs by the same factor. static let decodeSliceSize = 8 - static let maximumBufferedStanzas = 48 + static let maximumBufferedStanzas = 96 /// A short yield between archived decryption slices keeps touch handling /// responsive without the 8 ms per single message that previously dominated /// catch-up time. diff --git a/Sources/Shared/Models/OlderHistoryScrollPolicy.swift b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift new file mode 100644 index 0000000..95e7c87 --- /dev/null +++ b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift @@ -0,0 +1,30 @@ +import Foundation + +/// One interactive backward-history page handed to the UI: whether older +/// history remains and the cursor the next request must use. +struct OlderHistoryScrollPage: Equatable, Sendable { + let hasMore: Bool + let nextBefore: String? +} + +/// Interactive scroll-back policy. The cursor for the next page must come +/// from the server's RSM of the page that was just returned, not from +/// the oldest local message: a page can consist entirely of reactions, +/// retractions or already-known stanzas, which insert no new message rows, so +/// a locally computed anchor would never advance and every subsequent scroll +/// would re-request the same page forever (spinner spins, nothing loads). +enum OlderHistoryScrollPolicy { + /// - Parameter complete: the server's flag. + /// - Parameter pageFirstID: RSM UID of the returned page — the + /// oldest item in it, i.e. the anchor for the next, older page. + static func page( + complete: Bool, + pageFirstID: String? + ) -> OlderHistoryScrollPage { + let nextBefore = ArchiveSyncCheckpoint.normalizedCursor(pageFirstID) + // An empty page (or one without RSM) cannot advance the cursor, so it + // must never report "more" — otherwise the UI would loop on it. + let hasMore = !complete && nextBefore != nil + return OlderHistoryScrollPage(hasMore: hasMore, nextBefore: nextBefore) + } +} diff --git a/Sources/Shared/Models/VideoNoteRotationPolicy.swift b/Sources/Shared/Models/VideoNoteRotationPolicy.swift new file mode 100644 index 0000000..78e438e --- /dev/null +++ b/Sources/Shared/Models/VideoNoteRotationPolicy.swift @@ -0,0 +1,47 @@ +import CoreGraphics + +#if os(iOS) + import UIKit +#endif + +/// Clockwise rotation, in degrees, applied to the video-note capture so the +/// recorded QuickTime track plays upright. The camera sensor is +/// landscape-native: per Apple's videoOrientation<->videoRotationAngle +/// equivalence a portrait recording needs a 90-degree clockwise rotation +/// (a QuickTime track matrix), never 0 — 0 leaves the circle sideways. +enum VideoNoteRotationPolicy { + enum InterfaceOrientation: Equatable, Sendable { + case portrait + case portraitUpsideDown + case landscapeLeft + case landscapeRight + } + + static func angle(for orientation: InterfaceOrientation) -> CGFloat { + switch orientation { + case .portrait: return 90 + case .portraitUpsideDown: return 270 + case .landscapeLeft: return 180 + case .landscapeRight: return 0 + } + } + + #if os(iOS) + /// The orientation of the interface the user actually sees. The device + /// sensor (UIDevice.current.orientation) can report landscape or + /// face-up while the app stays portrait, which is exactly how a + /// portrait circle ended up encoded sideways. + static var currentInterfaceOrientation: InterfaceOrientation { + let orientation = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .interfaceOrientation + switch orientation { + case .portraitUpsideDown: return .portraitUpsideDown + case .landscapeLeft: return .landscapeLeft + case .landscapeRight: return .landscapeRight + default: return .portrait + } + } + #endif +} diff --git a/Sources/Shared/Services/VideoNoteRecorder.swift b/Sources/Shared/Services/VideoNoteRecorder.swift index 9fd0ba0..204f9f0 100644 --- a/Sources/Shared/Services/VideoNoteRecorder.swift +++ b/Sources/Shared/Services/VideoNoteRecorder.swift @@ -1,6 +1,13 @@ @preconcurrency import AVFoundation import Combine +import CoreImage import Foundation +import os + +private let videoNoteFinalizeLogger = Logger( + subsystem: "Luma", + category: "video-note-finalize" +) #if os(iOS) import UIKit @@ -17,6 +24,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe @Published private(set) var isRecording = false @Published private(set) var elapsed: TimeInterval = 0 @Published private(set) var isUsingFrontCamera = true + @Published private(set) var hasAlternateCamera = false @Published private(set) var isMicrophoneMuted = false let session = AVCaptureSession() @@ -47,6 +55,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe private var startupTimeoutTask: Task? private var finalizationTimeoutTask: Task? private var fileValidationTask: Task? + private var cameraSwitchContinuation: CheckedContinuation? + private var cameraFlipTimeoutTask: Task? + private var cameraFlipRequested = false + private var restartingSegmentForCameraFlip = false + private var recordedSegments: [URL] = [] + private var segmentsTotalDuration: TimeInterval = 0 private struct CaptureGraph { let cameraInput: AVCaptureDeviceInput @@ -95,7 +109,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe #if os(iOS) try AVAudioSession.sharedInstance().setCategory( .playAndRecord, - mode: .videoRecording, + // .default keeps voice processing and automatic gain control: + // a talking circle recorded at arm's length (front camera) + // stays loud and consistent, unlike .videoRecording which + // disables AGC and records the raw microphone level. + mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP] ) try AVAudioSession.sharedInstance().setActive(true) @@ -108,6 +126,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe cameraInput = graph.cameraInput microphoneInput = graph.microphoneInput isUsingFrontCamera = graph.usesFrontCamera + hasAlternateCamera = + hasCamera(at: graph.usesFrontCamera ? .back : .front) configureVideoConnection() try await startCaptureSession(generation: generation) @@ -137,8 +157,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe requestedMinimumDuration = 0 activeRecordingURL = url lifecycle = .starting - startedAt = nil - elapsed = 0 + if !restartingSegmentForCameraFlip { + startedAt = nil + elapsed = 0 + } + restartingSegmentForCameraFlip = false isRecording = true let output = movieOutput @@ -182,54 +205,153 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } + /// Flips between the front and the back camera. While recording, + /// AVFoundation tears the active movie down when its input is removed, so + /// the current segment is closed first, the camera is swapped, and a new + /// segment starts; the segments are merged back together on finalization. func switchCamera() async throws { #if os(iOS) guard isPrepared, - lifecycle == .prepared, + hasAlternateCamera, let currentInput = cameraInput else { throw VideoNoteRecorderError.notPrepared } - let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front - guard - let camera = AVCaptureDevice.default( - .builtInWideAngleCamera, - for: .video, - position: nextPosition - ) - else { - throw VideoNoteRecorderError.alternateCameraUnavailable - } - - let nextInput = try AVCaptureDeviceInput(device: camera) - let session = session - try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation) in - sessionQueue.async { - session.beginConfiguration() - session.removeInput(currentInput) - guard session.canAddInput(nextInput) else { - if session.canAddInput(currentInput) { - session.addInput(currentInput) - } - session.commitConfiguration() - continuation.resume(throwing: VideoNoteRecorderError.configurationFailed) - return - } - session.addInput(nextInput) - session.commitConfiguration() - continuation.resume() + switch lifecycle { + case .prepared: + try await swapCameraInput(replacing: currentInput) + case .starting, .recording: + try await stopSegmentForCameraFlip() + guard let input = cameraInput else { + throw VideoNoteRecorderError.notPrepared } + try await swapCameraInput(replacing: input) + restartingSegmentForCameraFlip = true + try start() + case .stopping, .idle: + throw VideoNoteRecorderError.notPrepared } - - cameraInput = nextInput - isUsingFrontCamera = nextPosition == .front - configureVideoConnection() #else throw VideoNoteRecorderError.alternateCameraUnavailable #endif } + /// Closes the in-flight segment without finishing the whole recording: + /// the file is kept (when non-empty), per-segment state resets, and the + /// pending switchCamera call resumes. + private func stopSegmentForCameraFlip() async throws { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + cameraSwitchContinuation = continuation + cameraFlipRequested = true + requestStop(afterMinimumDuration: 0) + scheduleCameraFlipTimeout() + } + } + + private func scheduleCameraFlipTimeout() { + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard !Task.isCancelled, + let self, + let continuation = self.cameraSwitchContinuation + else { return } + self.cameraSwitchContinuation = nil + self.cameraFlipRequested = false + continuation.resume( + throwing: VideoNoteRecorderError.finalizationTimedOut + ) + } + } + + private func handleCameraFlipSegment(url: URL) { + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = nil + let continuation = cameraSwitchContinuation + cameraSwitchContinuation = nil + + fileValidationTask?.cancel() + fileValidationTask = Task { @MainActor [weak self] in + guard let self else { return } + let inspection = await self.fileInspector.inspect( + url: url, + fallbackDuration: self.measuredWallClockDuration + ) + guard !Task.isCancelled, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) + else { return } + self.fileValidationTask = nil + if let inspection, inspection.duration > 0.05 { + self.recordedSegments.append(url) + self.segmentsTotalDuration += inspection.duration + } else { + try? FileManager.default.removeItem(at: url) + } + self.resetForNextSegment() + continuation?.resume(returning: ()) + } + } + + private func resetForNextSegment() { + lifecycle = .prepared + activeRecordingURL = nil + discardCurrentRecording = false + stopRequested = false + requestedMinimumDuration = 0 + isRecording = false + timer?.invalidate() + timer = nil + } + + private func swapCameraInput( + replacing currentInput: AVCaptureDeviceInput + ) async throws { + let nextPosition: AVCaptureDevice.Position = + isUsingFrontCamera ? .back : .front + guard + let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: nextPosition + ) + else { + throw VideoNoteRecorderError.alternateCameraUnavailable + } + + let nextInput = try AVCaptureDeviceInput(device: camera) + let session = session + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + sessionQueue.async { + session.beginConfiguration() + session.removeInput(currentInput) + guard session.canAddInput(nextInput) else { + if session.canAddInput(currentInput) { + session.addInput(currentInput) + } + session.commitConfiguration() + continuation.resume( + throwing: VideoNoteRecorderError.configurationFailed + ) + return + } + session.addInput(nextInput) + session.commitConfiguration() + continuation.resume() + } + } + + cameraInput = nextInput + isUsingFrontCamera = nextPosition == .front + hasAlternateCamera = + hasCamera(at: nextPosition == .front ? .back : .front) + configureVideoConnection() + } + func toggleMicrophoneMuted() { guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return } isMicrophoneMuted.toggle() @@ -261,7 +383,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe error: Error? ) { Task { @MainActor [weak self] in - self?.recordingDidFinish(url: outputFileURL, error: error) + await self?.recordingDidFinish(url: outputFileURL, error: error) } } @@ -369,7 +491,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } let remaining = VideoNoteStopPolicy.remainingRecordedDuration( - recordedDuration: snapshot.recordedDuration, + recordedDuration: snapshot.recordedDuration + + self.segmentsTotalDuration, minimumDuration: minimumDuration ) if remaining <= 0 || Date() >= mediaDeadline { break } @@ -408,7 +531,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } - private func recordingDidFinish(url: URL, error: Error?) { + private func recordingDidFinish(url: URL, error: Error?) async { guard VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: activeRecordingURL, @@ -433,6 +556,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe isRecording = false lifecycle = .stopping + if cameraFlipRequested { + // A flip during recording first closes the current segment: keep + // it and hand control back to the pending switchCamera call. + cameraFlipRequested = false + handleCameraFlipSegment(url: url) + return + } + let fallbackDuration = measuredWallClockDuration let shouldDiscard = discardCurrentRecording let wasStopRequested = stopRequested @@ -442,6 +573,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe return } + // Release the camera BEFORE the finalize re-encode runs. While the + // capture session is still live, the writer's encoder contends with + // it for the media hardware and its input can block forever in + // isReadyForMoreMediaData — the «Завершение…» hang. + await flushCaptureSessionForFinalization() + fileValidationTask?.cancel() fileValidationTask = Task { @MainActor [weak self] in guard let self else { return } @@ -467,17 +604,46 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( stopRequested: wasStopRequested, error: error, - recordedDuration: inspection.duration, + recordedDuration: self.segmentsTotalDuration + + inspection.duration, maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration ), - VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) + VideoNoteStopPolicy.isValidFinalDuration( + self.segmentsTotalDuration + inspection.duration + ) { - self.finishAndReset( - .success( - Recording( - url: url, - duration: inspection.duration - ))) + let totalDuration = + self.segmentsTotalDuration + inspection.duration + // Merge the flip segments back together (passthrough, no + // re-encode). The circle bubble crops the picture visually. + let finalization = await self.fileInspector.finalizeVideoNote( + segments: self.recordedSegments + [url] + ) + guard !Task.isCancelled, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) + else { return } + if let finalization { + if finalization.url != url { + try? FileManager.default.removeItem(at: url) + } + self.finishAndReset( + .success( + Recording( + url: finalization.url, + duration: finalization.duration + ))) + } else { + // The transcode fell through; the raw file was already + // validated, so send it uncropped instead of losing the + // recording entirely. + self.finishAndReset( + .success( + Recording(url: url, duration: totalDuration) + )) + } return } @@ -546,6 +712,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe continuation.resume() } } + // stopRunning is asynchronous: wait until the camera pipeline really + // released its media resources. The finalize re-encode must not start + // while the capture session still holds the encoder/muxer, otherwise + // the writer's inputs never become ready. + let stopDeadline = Date().addingTimeInterval(10) + while session.isRunning, Date() < stopDeadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } } private func scheduleStartupTimeout(for url: URL) { @@ -670,6 +844,17 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe finalizationTimeoutTask = nil fileValidationTask?.cancel() fileValidationTask = nil + cameraSwitchContinuation = nil + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = nil + cameraFlipRequested = false + restartingSegmentForCameraFlip = false + let segments = recordedSegments + recordedSegments = [] + segmentsTotalDuration = 0 + for segment in segments { + try? FileManager.default.removeItem(at: segment) + } timer?.invalidate() timer = nil resetCaptureGraph() @@ -683,6 +868,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe isPrepared = false isRecording = false isUsingFrontCamera = true + hasAlternateCamera = false isMicrophoneMuted = false #if os(iOS) try? AVAudioSession.sharedInstance().setActive( @@ -713,42 +899,27 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe connection.isVideoMirrored = isUsingFrontCamera } #if os(iOS) - if #available(iOS 17.0, *) { - let angle = videoRotationAngle - if connection.isVideoRotationAngleSupported(angle) { - connection.videoRotationAngle = angle - } - } else { - if connection.isVideoOrientationSupported { - connection.videoOrientation = Self.currentVideoOrientation - } + // iOS records upright portrait at capture time; macOS keeps the + // camera's native landscape orientation (the circle bubble crops + // it visually). + let rotationAngle = videoRotationAngle + if connection.isVideoRotationAngleSupported(rotationAngle) { + connection.videoRotationAngle = rotationAngle } #endif } #if os(iOS) private var videoRotationAngle: CGFloat { - switch UIDevice.current.orientation { - case .portraitUpsideDown: return 180 - case .landscapeLeft: return 90 - case .landscapeRight: return 270 - default: return 0 - } + // The interface orientation is authoritative: it is what the user + // actually sees. The device sensor can claim landscape (or + // face-up) while the app stays portrait, and recording with the + // sensor value rotated portrait circles by 90 degrees. + VideoNoteRotationPolicy.angle( + for: VideoNoteRotationPolicy.currentInterfaceOrientation + ) } - @available(iOS, deprecated: 17.0) - private static var currentVideoOrientation: AVCaptureVideoOrientation { - switch UIDevice.current.orientation { - case .portraitUpsideDown: - return .portraitUpsideDown - case .landscapeLeft: - return .landscapeRight - case .landscapeRight: - return .landscapeLeft - default: - return .portrait - } - } #endif #if os(iOS) @@ -785,6 +956,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } + private func hasCamera(at position: AVCaptureDevice.Position) -> Bool { + AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: position + ) != nil + } + private func configureCaptureGraph() async throws -> CaptureGraph { let session = session let movieOutput = movieOutput @@ -885,6 +1064,141 @@ private actor VideoNoteFileInspector { let duration: TimeInterval } + struct Finalization: Sendable { + let url: URL + let duration: TimeInterval + } + + private struct Composition { + let asset: AVAsset + let videoTrack: AVMutableCompositionTrack + let transform: CGAffineTransform + let duration: TimeInterval + } + + /// Hands the finished recording back for sending. Camera flips split a + /// recording into several .mov files, so the segments are merged back + /// Hands the finished recording back for sending. The capture is + /// already rotated to portrait at record time, so no re-encode is needed: + /// the raw file is sent as-is and camera flips are merged with a + /// passthrough export (audio and video stay bit-exact). + func finalizeVideoNote(segments: [URL]) async -> Finalization? { + videoNoteFinalizeLogger.info( + "finalize start segments=\(segments.count)" + ) + if segments.count == 1 { + let duration = + (try? await AVURLAsset(url: segments[0]).load(.duration) + .seconds) ?? 0 + videoNoteFinalizeLogger.info("finalize raw") + return Finalization(url: segments[0], duration: max(0, duration)) + } + videoNoteFinalizeLogger.info("finalize passthrough") + return await Self.passthroughMerge(segments: segments) + } + + private static func composition(from urls: [URL]) async -> Composition? { + let composition = AVMutableComposition() + guard + let videoTrack = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + else { return nil } + // The audio track is created lazily and only when a segment really + // has audio. An empty audio track breaks the macOS exporter. + var compositionAudioTrack: AVMutableCompositionTrack? + var cursor = CMTime.zero + var transform = CGAffineTransform.identity + var hasVideo = false + for url in urls { + let asset = AVURLAsset(url: url) + guard + let duration = try? await asset.load(.duration), + let source = try? await asset.loadTracks(withMediaType: .video) + .first + else { return nil } + let range = CMTimeRange(start: .zero, duration: duration) + do { + try videoTrack.insertTimeRange(range, of: source, at: cursor) + if let audio = try? await asset.loadTracks( + withMediaType: .audio + ).first { + if compositionAudioTrack == nil { + compositionAudioTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + } + if let compositionAudioTrack { + try? compositionAudioTrack.insertTimeRange( + range, + of: audio, + at: cursor + ) + } + } + } catch { + return nil + } + if !hasVideo { + transform = source.preferredTransform + hasVideo = true + } + cursor = cursor + duration + } + videoTrack.preferredTransform = transform + return Composition( + asset: composition, + videoTrack: videoTrack, + transform: transform, + duration: cursor.seconds + ) + } + + private static func passthroughMerge( + segments: [URL] + ) async -> Finalization? { + guard let composition = await composition(from: segments) else { + return nil + } + guard let outputURL = finalizationOutputURL() else { return nil } + guard let exporter = AVAssetExportSession( + asset: composition.asset, + presetName: AVAssetExportPresetPassthrough + ) else { return nil } + exporter.outputURL = outputURL + exporter.outputFileType = .mov + exporter.shouldOptimizeForNetworkUse = true + do { + try await exporter.export(to: outputURL, as: .mov) + } catch { + try? FileManager.default.removeItem(at: outputURL) + return nil + } + guard exporter.status == .completed else { + try? FileManager.default.removeItem(at: outputURL) + return nil + } + return Finalization(url: outputURL, duration: composition.duration) + } + + private static func finalizationOutputURL() -> URL? { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaRecordings", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.appendingPathComponent( + "video-note-final-\(UUID().uuidString).mov" + ) + } catch { + return nil + } + } + func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? { let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000] for delay in retryDelays { diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index fc628bd..bfb52ed 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -61,6 +61,10 @@ struct ChatView: View { @State private var isNearTimelineBottom = true @State private var historyLoadAnchorID: String? @State private var historyTopTriggerArmed = true + @State private var historyTopTriggerVisible = false + @State private var historyLoadEntryCount = 0 + @State private var historyAutoContinueCount = 0 + @State private var emptyHistoryRetryCount = 0 @State private var activeCaptureMode: ComposerCaptureMode? @State private var preparingCaptureMode: ComposerCaptureMode? @State private var captureGestureIsActive = false @@ -122,6 +126,10 @@ struct ChatView: View { initialValue: nil ) _hasCompletedInitialScroll = State(initialValue: false) + _historyTopTriggerVisible = State(initialValue: false) + _historyLoadEntryCount = State(initialValue: 0) + _historyAutoContinueCount = State(initialValue: 0) + _emptyHistoryRetryCount = State(initialValue: 0) _isNearTimelineBottom = State(initialValue: true) _historyLoadAnchorID = State(initialValue: nil) _historyTopTriggerArmed = State(initialValue: true) @@ -365,8 +373,12 @@ struct ChatView: View { isNearTimelineBottom = true historyLoadAnchorID = nil historyTopTriggerArmed = true + historyTopTriggerVisible = false + historyAutoContinueCount = 0 + emptyHistoryRetryCount = 0 } .onDisappear { + model.endConversationViewing(jid: conversation.jid) model.endComposerActivity(in: liveConversation) attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask = nil @@ -487,17 +499,16 @@ struct ChatView: View { .frame(height: 1) .accessibilityHidden(true) .onAppear { + historyTopTriggerVisible = true guard historyTopTriggerArmed, !model.isLoadingOlderHistory else { return } - historyTopTriggerArmed = false - historyLoadAnchorID = - timelineEntries.first?.id - model - .loadOlderHistoryForSelectedConversation() + triggerOlderHistoryLoad() } .onDisappear { + historyTopTriggerVisible = false historyTopTriggerArmed = true + historyAutoContinueCount = 0 } if model.isLoadingOlderHistory { ProgressView() @@ -653,14 +664,49 @@ struct ChatView: View { .onChange(of: model.isLoadingOlderHistory) { wasLoading, isLoading in - guard wasLoading, !isLoading, - let anchor = historyLoadAnchorID - else { return } - Task { @MainActor in - await Task.yield() - proxy.scrollTo(anchor, anchor: .top) + guard wasLoading, !isLoading else { return } + // Restore the scroll position so the newly loaded + // page appears above the previously visible one. Only + // while the user is still at the top: a slow page must + // never yank the timeline away from where the user + // scrolled while it was loading. + if let anchor = historyLoadAnchorID, + historyTopTriggerVisible + { + Task { @MainActor in + await Task.yield() + proxy.scrollTo(anchor, anchor: .top) + historyLoadAnchorID = nil + } + } else { historyLoadAnchorID = nil } + // The trigger is one-shot per visibility cycle. + // Re-arm it whenever a load settles so the next + // scroll gesture can fire again even if this page + // inserted nothing. + historyTopTriggerArmed = true + if timelineEntries.isEmpty { + handleEmptyHistoryLoadCompleted() + } else if historyTopTriggerVisible { + // The page left the sentinel on screen: it either + // inserted no rows (reactions, duplicates) or the + // anchor scroll did not push it away. While the + // user stays at the top, continue through such + // pages so scroll-up never appears dead. Every + // page advances the server cursor, so this loop + // is bounded by the archive itself. + if timelineEntries.count + == historyLoadEntryCount, + model.hasMoreOlderHistory, + historyAutoContinueCount < 6 + { + historyAutoContinueCount += 1 + triggerOlderHistoryLoad() + } else { + historyAutoContinueCount = 0 + } + } } .onChange(of: timelineEntries.count) { _, _ in selectedMessageIDs.formIntersection( @@ -1840,6 +1886,37 @@ struct ChatView: View { } } + /// Starts one interactive older-history page load from the top sentinel. + /// Remembers the first visible entry so the viewport can be restored when + /// the page arrives. + private func triggerOlderHistoryLoad() { + historyTopTriggerArmed = false + historyLoadAnchorID = timelineEntries.first?.id + historyLoadEntryCount = timelineEntries.count + model.loadOlderHistoryForSelectedConversation() + } + + /// A finished load left the timeline empty. Retry a bounded number of + /// times with a short delay: the first page of an empty chat can fail + /// transiently (timeout, reconnect), and an empty timeline has no scroll + /// gesture that could retry it manually. + private func handleEmptyHistoryLoadCompleted() { + guard model.hasMoreOlderHistory, + !model.isLoadingOlderHistory, + emptyHistoryRetryCount < 3 + else { return } + emptyHistoryRetryCount += 1 + Task { @MainActor [weak model] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled, + timelineEntries.isEmpty, + model?.hasMoreOlderHistory == true, + model?.isLoadingOlderHistory == false + else { return } + model?.loadOlderHistoryForSelectedConversation() + } + } + private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async { diff --git a/Sources/Shared/UI/VideoNoteCaptureView.swift b/Sources/Shared/UI/VideoNoteCaptureView.swift index 86eb2e5..9f8b3f0 100644 --- a/Sources/Shared/UI/VideoNoteCaptureView.swift +++ b/Sources/Shared/UI/VideoNoteCaptureView.swift @@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View { @StateObject private var recorder = VideoNoteRecorder() @State private var errorMessage: String? @State private var isFinalizing = false + @State private var isSwitchingCamera = false let onComplete: (VideoNoteRecorder.Recording) -> Void @@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View { .frame(maxWidth: 420) .aspectRatio(1, contentMode: .fit) .overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1)) + #if os(iOS) + .overlay(alignment: .bottomTrailing) { + if recorder.hasAlternateCamera, recorder.isPrepared { + Button(action: flipCamera) { + Image( + systemName: + "arrow.triangle.2.circlepath.camera.fill" + ) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.white) + .padding(10) + .background(.ultraThinMaterial, in: Circle()) + } + .buttonStyle(.plain) + .padding(14) + .disabled(isSwitchingCamera || isFinalizing) + .accessibilityLabel("Переключить камеру") + } + } + #endif Text(recordingStatus) .font(.system(.title3, design: .monospaced).weight(.semibold)) @@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View { #endif } + private func flipCamera() { + guard !isSwitchingCamera, !isFinalizing else { return } + isSwitchingCamera = true + Task { @MainActor in + do { + try await recorder.switchCamera() + } catch { + errorMessage = error.localizedDescription + } + isSwitchingCamera = false + } + } + private func formatted(_ duration: TimeInterval) -> String { let seconds = max(0, Int(duration.rounded(.down))) return String(format: "%d:%02d", seconds / 60, seconds % 60) diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 044e630..0198057 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -319,7 +319,7 @@ final class XMPPService { private var mucCursorFallbackArchives: Set = [] private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:] private var olderHistoryQueryID: String? - private var olderHistoryCompletion: ((Result) -> Void)? + private var olderHistoryCompletion: ((Result) -> Void)? private var olderHistoryTimeoutTask: Task? /// When non-nil, archived mutations produced while applying an interactive /// backward-history page are collected here instead of the catch-up @@ -989,12 +989,13 @@ final class XMPPService { /// Loads one older MAM page for a single conversation. `before` is the /// oldest server/MAM id currently known by the UI. For direct chats the /// query is scoped with XEP-0313 `with`; for MUC the IQ is addressed to the - /// room archive itself. + /// room archive itself. The completion carries the cursor for the next, + /// older page (RSM of the returned page). func loadOlderHistory( conversationJID: String, isGroup: Bool, before: String?, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { guard let client, client.state == .connected() else { completion(.failure(LumaXMPPError.notConnected)) @@ -1015,7 +1016,7 @@ final class XMPPService { conversationJID: String, isGroup: Bool, before: String?, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { guard let client, client.state == .connected() else { completion(.failure(LumaXMPPError.notConnected)) @@ -1132,7 +1133,18 @@ final class XMPPService { } switch result { case .success(let response): - self.finishOlderHistory(result: .success(!response.complete)) + // The next page's anchor is the server's RSM + // of this page. Pages that insert nothing (reactions, + // retractions, duplicates) still advance the cursor, + // so the next scroll requests genuinely older history + // instead of the same page again. + self.finishOlderHistory( + result: .success( + OlderHistoryScrollPolicy.page( + complete: response.complete, + pageFirstID: response.rsm?.first + ) + )) case .failure(let error): // Do not leave the UI in the loading state. A failed // interactive query is recoverable by the next scroll. @@ -1180,7 +1192,7 @@ final class XMPPService { /// Every terminal path (success, failure, timeout, disconnect, background) /// funnels through here so the UI's "loading older history" spinner can /// never be left stuck. - private func finishOlderHistory(result: Result) { + private func finishOlderHistory(result: Result) { guard olderHistoryCompletion != nil else { return } let completion = olderHistoryCompletion olderHistoryCompletion = nil @@ -2295,15 +2307,6 @@ final class XMPPService { // message path. return default: - if outgoing { - // Our own outgoing echo cannot be decrypted back (no - // encrypt-to-self key, or a stale self-session). The - // optimistic local copy already carries the content, and - // messages sent from other devices have nothing we could - // render. Other clients never surface these stanzas as - // messages, so dropping them matches the server history. - return - } // Some clients include a plaintext fallback alongside the // OMEMO payload. When decryption fails, prefer that // fallback so a readable message is never shown as undecryptable. @@ -2312,6 +2315,15 @@ final class XMPPService { fingerprint = nil contentMessage = message } else { + // An undecryptable outgoing stanza is still part of the + // user's own history — e.g. messages written on another + // device whose keys this device never had. Dropping them + // silently made such conversations look empty even though + // the archive has history, so surface them as a + // decryption-failed placeholder instead. Echoes of + // messages sent from this device carry the same origin-id + // as the local optimistic copy and are merged there, so + // they never produce a second bubble. security = .decryptionFailed fingerprint = nil contentMessage = nil @@ -2572,13 +2584,17 @@ final class XMPPService { return default: if outgoing { + // Register the room-assigned stanza-id for future + // replies even when this device cannot decrypt the + // payload. Fall through to the placeholder path: the + // message is part of the room's history and must not + // vanish from the timeline. emitGroupEchoIfPossible( roomJID: roomJID, messageID: id, stanzaID: stanzaID, senderJID: senderJID ) - return } if message.body?.isEmpty == false { security = .plaintext diff --git a/Tests/ArchiveMessageBatchPolicyTests.swift b/Tests/ArchiveMessageBatchPolicyTests.swift index cdccd5e..e400bd6 100644 --- a/Tests/ArchiveMessageBatchPolicyTests.swift +++ b/Tests/ArchiveMessageBatchPolicyTests.swift @@ -2,9 +2,8 @@ import XCTest @testable import Luma final class ArchiveMessageBatchPolicyTests: XCTestCase { - func testMamPagesStaySmallEnoughForInteractiveUI() { - XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.pageSize, 32) - XCTAssertGreaterThan(ArchiveMessageBatchPolicy.pageSize, 0) + func testInteractivePageSize() { + XCTAssertEqual(ArchiveMessageBatchPolicy.pageSize, 40) } func testStanzasAreDecodedInSmallBatchesPerMainActorSlice() { diff --git a/Tests/OlderHistoryScrollPolicyTests.swift b/Tests/OlderHistoryScrollPolicyTests.swift new file mode 100644 index 0000000..3bd4d64 --- /dev/null +++ b/Tests/OlderHistoryScrollPolicyTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Luma + +final class OlderHistoryScrollPolicyTests: XCTestCase { + func testIncompletePageWithFirstCursorContinues() { + let page = OlderHistoryScrollPolicy.page( + complete: false, + pageFirstID: " mam-17 " + ) + + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.nextBefore, "mam-17") + } + + func testCompletePageStopsEvenWithCursor() { + let page = OlderHistoryScrollPolicy.page( + complete: true, + pageFirstID: "mam-17" + ) + + XCTAssertFalse(page.hasMore) + XCTAssertEqual(page.nextBefore, "mam-17") + } + + func testEmptyPageNeverReportsMore() { + // A server that keeps answering without RSM must not leave + // the UI re-requesting the same page in a spinner loop. + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: nil) + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } + + func testWhitespaceOnlyCursorIsDiscarded() { + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: " ") + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } + + func testPageFirstIDMustNotBecomeAnEmptyCursor() { + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: "") + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } +} diff --git a/Tests/VideoNoteRotationPolicyTests.swift b/Tests/VideoNoteRotationPolicyTests.swift new file mode 100644 index 0000000..f924057 --- /dev/null +++ b/Tests/VideoNoteRotationPolicyTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Luma + +final class VideoNoteRotationPolicyTests: XCTestCase { + func testPortraitNeedsClockwiseQuarterTurn() { + // The sensor is landscape-native; an upright portrait circle must be + // rotated 90 degrees clockwise in the QuickTime track matrix. + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .portrait), + 90 + ) + } + + func testPortraitUpsideDownNeedsThreeQuarterTurn() { + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .portraitUpsideDown), + 270 + ) + } + + func testLandscapeOrientationsMapToNativeAngles() { + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .landscapeLeft), + 180 + ) + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .landscapeRight), + 0 + ) + } + + func testAllAnglesAreSupportedCaptureAngles() { + let orientations: [VideoNoteRotationPolicy.InterfaceOrientation] = [ + .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight, + ] + for orientation in orientations { + let angle = VideoNoteRotationPolicy.angle(for: orientation) + XCTAssertTrue( + [CGFloat(0), 90, 180, 270].contains(angle), + "unexpected angle \(angle) for \(orientation)" + ) + } + } +}