some fixes
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-09-07 19:40:47 +07:00
1 parent cf2bda9c54
commit e28a4043ee
13 files changed
+793 -119

No files matched your search

+20
View File
@@ -15,6 +15,7 @@
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; };
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; }; 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; };
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.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 */; }; 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; };
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.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 */; }; 3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; }; 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; };
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; 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 */; }; 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; };
39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; }; 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; };
3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; 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 */; }; 481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */; };
48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; }; 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; };
48BE02EB8AA84E9CD1354E1A /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.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 */; }; 496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; 49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; };
4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.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 */; }; A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; };
A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; }; A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; };
A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.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 */; }; A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */; };
A90142509385DE6E83818953 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; }; A90142509385DE6E83818953 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; };
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; }; ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; };
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; }; AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; };
ADB2CCC69CA91FA820B3A4CA /* MessageReactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92CDC8D3E0DA178D0E3F52 /* MessageReactionTests.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 */; }; AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; }; AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.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 = "<group>"; }; 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = "<group>"; };
43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; }; 43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = "<group>"; }; 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = "<group>"; };
4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicyTests.swift; sourceTree = "<group>"; };
4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = "<group>"; }; 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = "<group>"; };
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = "<group>"; }; 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = "<group>"; };
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = "<group>"; }; 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = "<group>"; };
@@ -377,6 +384,7 @@
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = "<group>"; }; 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = "<group>"; };
569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = "<group>"; }; 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = "<group>"; };
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = "<group>"; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = "<group>"; };
5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicy.swift; sourceTree = "<group>"; };
5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = "<group>"; }; 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = "<group>"; };
5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = "<group>"; }; 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = "<group>"; };
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = "<group>"; }; 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = "<group>"; };
@@ -427,6 +435,7 @@
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = "<group>"; }; A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = "<group>"; };
A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaImageCache.swift; sourceTree = "<group>"; }; A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaImageCache.swift; sourceTree = "<group>"; };
A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSendActivityTracker.swift; sourceTree = "<group>"; }; A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSendActivityTracker.swift; sourceTree = "<group>"; };
A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicyTests.swift; sourceTree = "<group>"; };
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = "<group>"; }; A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = "<group>"; };
A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = "<group>"; }; A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = "<group>"; };
A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = "<group>"; }; A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = "<group>"; };
@@ -452,6 +461,7 @@
C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = "<group>"; }; C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = "<group>"; };
C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = "<group>"; }; C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = "<group>"; };
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = "<group>"; }; C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = "<group>"; };
C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicy.swift; sourceTree = "<group>"; };
CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = "<group>"; }; CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = "<group>"; };
CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = "<group>"; }; CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = "<group>"; };
CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = "<group>"; }; CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = "<group>"; };
@@ -561,11 +571,13 @@
6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */, 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */,
9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */, 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */,
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */, 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */,
5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */,
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */, A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */,
6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */, 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */,
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */, E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */,
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */, E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */,
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */, 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */,
C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -661,6 +673,7 @@
BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */, BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */,
77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */, 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */,
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */,
A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */,
1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */,
37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */,
13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */,
@@ -669,6 +682,7 @@
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */,
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */,
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */, D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */,
4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */,
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */, 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */,
1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */, 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */,
); );
@@ -1075,6 +1089,7 @@
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */, D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */,
66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */, 66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */,
7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */, 7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */,
A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */,
6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */, 6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */,
C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */, C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */,
5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */, 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */,
@@ -1096,6 +1111,7 @@
01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */, 01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */,
275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */, 275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */,
1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */, 1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */,
32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */,
F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */, F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -1196,6 +1212,7 @@
B91AA414D00C6B54BE32ED25 /* NotificationCoordinator.swift in Sources */, B91AA414D00C6B54BE32ED25 /* NotificationCoordinator.swift in Sources */,
D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */, D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */,
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */, 097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */,
AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */,
FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */, FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */,
46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */, 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */,
6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */, 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */,
@@ -1217,6 +1234,7 @@
1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */, 1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */,
B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */, B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */,
CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */, CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */,
054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */,
FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */, FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -1258,6 +1276,7 @@
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */, 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */,
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */,
9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */,
A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */,
5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */,
1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */,
DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */, DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */,
@@ -1266,6 +1285,7 @@
3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */,
FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */, FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */,
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */, E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */,
48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */,
89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */, 89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */,
7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */, 7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */,
); );
+12
View File
@@ -18,12 +18,14 @@ required=(
Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
Sources/Shared/Models/ArchiveSyncWorkBudget.swift Sources/Shared/Models/ArchiveSyncWorkBudget.swift
Sources/Shared/Models/ArchiveMessageBatchPolicy.swift Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
Sources/Shared/Models/OlderHistoryScrollPolicy.swift
Sources/Shared/Models/ChatScrollPositionPolicy.swift Sources/Shared/Models/ChatScrollPositionPolicy.swift
Sources/Shared/Models/ChatTimelineEntry.swift Sources/Shared/Models/ChatTimelineEntry.swift
Sources/Shared/Models/ChatTypingPolicy.swift Sources/Shared/Models/ChatTypingPolicy.swift
Sources/Shared/Models/MessageReaction.swift Sources/Shared/Models/MessageReaction.swift
Sources/Shared/Models/MediaPickerSelectionPolicy.swift Sources/Shared/Models/MediaPickerSelectionPolicy.swift
Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift
Sources/Shared/Models/VideoNoteRotationPolicy.swift
Sources/Shared/Models/VideoNoteRecordingLifecycle.swift Sources/Shared/Models/VideoNoteRecordingLifecycle.swift
Sources/Shared/Models/MessageReplySwipePolicy.swift Sources/Shared/Models/MessageReplySwipePolicy.swift
Sources/Shared/Models/MessageReplyFallback.swift Sources/Shared/Models/MessageReplyFallback.swift
@@ -66,6 +68,7 @@ required=(
Tests/MediaViewerDismissGestureTests.swift Tests/MediaViewerDismissGestureTests.swift
Tests/ArchiveSyncCheckpointTests.swift Tests/ArchiveSyncCheckpointTests.swift
Tests/ArchiveSyncPaginationTests.swift Tests/ArchiveSyncPaginationTests.swift
Tests/OlderHistoryScrollPolicyTests.swift
Tests/ArchiveSyncRecoveryPolicyTests.swift Tests/ArchiveSyncRecoveryPolicyTests.swift
Tests/ArchiveSyncWorkBudgetTests.swift Tests/ArchiveSyncWorkBudgetTests.swift
Tests/ArchiveMessageBatchPolicyTests.swift Tests/ArchiveMessageBatchPolicyTests.swift
@@ -77,6 +80,7 @@ required=(
Tests/MediaPickerSelectionPolicyTests.swift Tests/MediaPickerSelectionPolicyTests.swift
Tests/VideoNoteRecordingCompletionPolicyTests.swift Tests/VideoNoteRecordingCompletionPolicyTests.swift
Tests/VideoNoteRecordingLifecycleTests.swift Tests/VideoNoteRecordingLifecycleTests.swift
Tests/VideoNoteRotationPolicyTests.swift
Tests/VideoNoteStopPolicyTests.swift Tests/VideoNoteStopPolicyTests.swift
Tests/MessageReplySwipeTests.swift Tests/MessageReplySwipeTests.swift
Tests/WatchVoiceMessageTests.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" echo "Incremental MAM passes must have a finite foreground work budget"
exit 1 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 if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then
echo "Failed MAM queries must not restart themselves indefinitely" echo "Failed MAM queries must not restart themselves indefinitely"
exit 1 exit 1
@@ -642,6 +650,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video'
echo "Video-note recording must not reuse the inline voice-message gesture" echo "Video-note recording must not reuse the inline voice-message gesture"
exit 1 exit 1
fi 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 || { grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || {
echo "Reusable video-note capture cleanup is missing" echo "Reusable video-note capture cleanup is missing"
exit 1 exit 1
+45 -11
View File
@@ -121,6 +121,11 @@ final class AppModel: ObservableObject {
private var lastSuccessfulMAMCursor: String? private var lastSuccessfulMAMCursor: String?
private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
private var hasMoreOlderHistoryByConversation: [String: Bool] = [:] private var hasMoreOlderHistoryByConversation: [String: Bool] = [:]
/// Server-side cursor (RSM <first> 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 pendingRoomPasswords: [String: String] = [:]
private var joiningRoomJIDs: Set<String> = [] private var joiningRoomJIDs: Set<String> = []
private var deletedGroupChatJIDs: Set<String> = [] private var deletedGroupChatJIDs: Set<String> = []
@@ -737,12 +742,13 @@ final class AppModel: ObservableObject {
func selectConversation(id: String) { func selectConversation(id: String) {
let normalized = id.lowercased() let normalized = id.lowercased()
selectedConversationID = normalized selectedConversationID = normalized
// if let index = conversations.firstIndex(where: { $0.jid == normalized }) { // Opening the chat marks it as read, so the unread badge disappears
// conversations[index].unreadCount = 0 // immediately and stays in sync with the watch.
// } if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
// schedulePersist() conversations[index].unreadCount = 0
// syncWatch() }
// hasMoreOlderHistory = true schedulePersist()
syncWatch()
if hasMoreOlderHistoryByConversation[normalized] == false, if hasMoreOlderHistoryByConversation[normalized] == false,
selectedMessages.isEmpty selectedMessages.isEmpty
{ {
@@ -754,6 +760,17 @@ final class AppModel: ObservableObject {
hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true 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() { func loadOlderHistoryForSelectedConversation() {
// While disconnected the request would only fail with // While disconnected the request would only fail with
// "not connected", which used to pop the global alert right over // "not connected", which used to pop the global alert right over
@@ -780,21 +797,37 @@ final class AppModel: ObservableObject {
isLoadingOlderHistory = true isLoadingOlderHistory = true
let conversationID = conversation.jid.lowercased() 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( xmpp.loadOlderHistory(
conversationJID: conversation.jid, conversationJID: conversation.jid,
isGroup: conversation.isGroup, isGroup: conversation.isGroup,
before: oldestServerID before: before
) { [weak self] result in ) { [weak self] result in
guard let self else { return } guard let self else { return }
self.isLoadingOlderHistory = false self.isLoadingOlderHistory = false
switch result { switch result {
case .success(let hasMore): case .success(let page):
// self.hasMoreOlderHistory = hasMore if let nextBefore = page.nextBefore {
self.hasMoreOlderHistoryByConversation[conversationID] = hasMore 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 { if self.selectedConversationID == conversationID {
self.hasMoreOlderHistory = hasMore self.hasMoreOlderHistory = page.hasMore
} }
case .failure(let error): case .failure(let error):
// Keep the cursor: the next scroll retries the same page.
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
} }
} }
@@ -935,6 +968,7 @@ final class AppModel: ObservableObject {
pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) } pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) }
pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") } pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") }
hasMoreOlderHistoryByConversation.removeValue(forKey: normalized) hasMoreOlderHistoryByConversation.removeValue(forKey: normalized)
olderHistoryCursorByConversation.removeValue(forKey: normalized)
pendingRoomPasswords.removeValue(forKey: normalized) pendingRoomPasswords.removeValue(forKey: normalized)
joiningRoomJIDs.remove(normalized) joiningRoomJIDs.remove(normalized)
localChatStateByConversation.removeValue(forKey: normalized) localChatStateByConversation.removeValue(forKey: normalized)
@@ -4,14 +4,14 @@ import Foundation
/// still published atomically; these values only bound network and decryption /// still published atomically; these values only bound network and decryption
/// work performed before that publication. /// work performed before that publication.
enum ArchiveMessageBatchPolicy { enum ArchiveMessageBatchPolicy {
static let pageSize = 24 static let pageSize = 40
static let bootstrapMessageLimit = 40 static let bootstrapMessageLimit = 40
/// Decrypt this many archived stanzas per main-actor slice before yielding. /// 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 /// 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 /// work; a small batch keeps frames mostly intact while cutting the number
/// of run-loop hand-offs by the same factor. /// of run-loop hand-offs by the same factor.
static let decodeSliceSize = 8 static let decodeSliceSize = 8
static let maximumBufferedStanzas = 48 static let maximumBufferedStanzas = 96
/// A short yield between archived decryption slices keeps touch handling /// A short yield between archived decryption slices keeps touch handling
/// responsive without the 8 ms per single message that previously dominated /// responsive without the 8 ms per single message that previously dominated
/// catch-up time. /// catch-up time.
@@ -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 <first> 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 <fin complete="true"> flag.
/// - Parameter pageFirstID: RSM <first> 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)
}
}
@@ -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
}
+390 -76
View File
@@ -1,6 +1,13 @@
@preconcurrency import AVFoundation @preconcurrency import AVFoundation
import Combine import Combine
import CoreImage
import Foundation import Foundation
import os
private let videoNoteFinalizeLogger = Logger(
subsystem: "Luma",
category: "video-note-finalize"
)
#if os(iOS) #if os(iOS)
import UIKit import UIKit
@@ -17,6 +24,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
@Published private(set) var isRecording = false @Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0 @Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var isUsingFrontCamera = true @Published private(set) var isUsingFrontCamera = true
@Published private(set) var hasAlternateCamera = false
@Published private(set) var isMicrophoneMuted = false @Published private(set) var isMicrophoneMuted = false
let session = AVCaptureSession() let session = AVCaptureSession()
@@ -47,6 +55,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
private var startupTimeoutTask: Task<Void, Never>? private var startupTimeoutTask: Task<Void, Never>?
private var finalizationTimeoutTask: Task<Void, Never>? private var finalizationTimeoutTask: Task<Void, Never>?
private var fileValidationTask: Task<Void, Never>? private var fileValidationTask: Task<Void, Never>?
private var cameraSwitchContinuation: CheckedContinuation<Void, Error>?
private var cameraFlipTimeoutTask: Task<Void, Never>?
private var cameraFlipRequested = false
private var restartingSegmentForCameraFlip = false
private var recordedSegments: [URL] = []
private var segmentsTotalDuration: TimeInterval = 0
private struct CaptureGraph { private struct CaptureGraph {
let cameraInput: AVCaptureDeviceInput let cameraInput: AVCaptureDeviceInput
@@ -95,7 +109,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
#if os(iOS) #if os(iOS)
try AVAudioSession.sharedInstance().setCategory( try AVAudioSession.sharedInstance().setCategory(
.playAndRecord, .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] options: [.defaultToSpeaker, .allowBluetoothHFP]
) )
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
@@ -108,6 +126,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
cameraInput = graph.cameraInput cameraInput = graph.cameraInput
microphoneInput = graph.microphoneInput microphoneInput = graph.microphoneInput
isUsingFrontCamera = graph.usesFrontCamera isUsingFrontCamera = graph.usesFrontCamera
hasAlternateCamera =
hasCamera(at: graph.usesFrontCamera ? .back : .front)
configureVideoConnection() configureVideoConnection()
try await startCaptureSession(generation: generation) try await startCaptureSession(generation: generation)
@@ -137,8 +157,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
requestedMinimumDuration = 0 requestedMinimumDuration = 0
activeRecordingURL = url activeRecordingURL = url
lifecycle = .starting lifecycle = .starting
startedAt = nil if !restartingSegmentForCameraFlip {
elapsed = 0 startedAt = nil
elapsed = 0
}
restartingSegmentForCameraFlip = false
isRecording = true isRecording = true
let output = movieOutput 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 { func switchCamera() async throws {
#if os(iOS) #if os(iOS)
guard isPrepared, guard isPrepared,
lifecycle == .prepared, hasAlternateCamera,
let currentInput = cameraInput let currentInput = cameraInput
else { else {
throw VideoNoteRecorderError.notPrepared throw VideoNoteRecorderError.notPrepared
} }
let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front switch lifecycle {
guard case .prepared:
let camera = AVCaptureDevice.default( try await swapCameraInput(replacing: currentInput)
.builtInWideAngleCamera, case .starting, .recording:
for: .video, try await stopSegmentForCameraFlip()
position: nextPosition guard let input = cameraInput else {
) throw VideoNoteRecorderError.notPrepared
else {
throw VideoNoteRecorderError.alternateCameraUnavailable
}
let nextInput = try AVCaptureDeviceInput(device: camera)
let session = session
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) 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()
} }
try await swapCameraInput(replacing: input)
restartingSegmentForCameraFlip = true
try start()
case .stopping, .idle:
throw VideoNoteRecorderError.notPrepared
} }
cameraInput = nextInput
isUsingFrontCamera = nextPosition == .front
configureVideoConnection()
#else #else
throw VideoNoteRecorderError.alternateCameraUnavailable throw VideoNoteRecorderError.alternateCameraUnavailable
#endif #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<Void, Error>) 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<Void, Error>) 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() { func toggleMicrophoneMuted() {
guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return } guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return }
isMicrophoneMuted.toggle() isMicrophoneMuted.toggle()
@@ -261,7 +383,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
error: Error? error: Error?
) { ) {
Task { @MainActor [weak self] in 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( let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
recordedDuration: snapshot.recordedDuration, recordedDuration: snapshot.recordedDuration
+ self.segmentsTotalDuration,
minimumDuration: minimumDuration minimumDuration: minimumDuration
) )
if remaining <= 0 || Date() >= mediaDeadline { break } 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 guard
VideoNoteRecordingLifecycle.acceptsCompletion( VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL, activeURL: activeRecordingURL,
@@ -433,6 +556,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
isRecording = false isRecording = false
lifecycle = .stopping 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 fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested let wasStopRequested = stopRequested
@@ -442,6 +573,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
return 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?.cancel()
fileValidationTask = Task { @MainActor [weak self] in fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
@@ -467,17 +604,46 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: wasStopRequested, stopRequested: wasStopRequested,
error: error, error: error,
recordedDuration: inspection.duration, recordedDuration: self.segmentsTotalDuration
+ inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
), ),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) VideoNoteStopPolicy.isValidFinalDuration(
self.segmentsTotalDuration + inspection.duration
)
{ {
self.finishAndReset( let totalDuration =
.success( self.segmentsTotalDuration + inspection.duration
Recording( // Merge the flip segments back together (passthrough, no
url: url, // re-encode). The circle bubble crops the picture visually.
duration: inspection.duration 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 return
} }
@@ -546,6 +712,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
continuation.resume() 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) { private func scheduleStartupTimeout(for url: URL) {
@@ -670,6 +844,17 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
finalizationTimeoutTask = nil finalizationTimeoutTask = nil
fileValidationTask?.cancel() fileValidationTask?.cancel()
fileValidationTask = nil 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?.invalidate()
timer = nil timer = nil
resetCaptureGraph() resetCaptureGraph()
@@ -683,6 +868,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
isPrepared = false isPrepared = false
isRecording = false isRecording = false
isUsingFrontCamera = true isUsingFrontCamera = true
hasAlternateCamera = false
isMicrophoneMuted = false isMicrophoneMuted = false
#if os(iOS) #if os(iOS)
try? AVAudioSession.sharedInstance().setActive( try? AVAudioSession.sharedInstance().setActive(
@@ -713,42 +899,27 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
connection.isVideoMirrored = isUsingFrontCamera connection.isVideoMirrored = isUsingFrontCamera
} }
#if os(iOS) #if os(iOS)
if #available(iOS 17.0, *) { // iOS records upright portrait at capture time; macOS keeps the
let angle = videoRotationAngle // camera's native landscape orientation (the circle bubble crops
if connection.isVideoRotationAngleSupported(angle) { // it visually).
connection.videoRotationAngle = angle let rotationAngle = videoRotationAngle
} if connection.isVideoRotationAngleSupported(rotationAngle) {
} else { connection.videoRotationAngle = rotationAngle
if connection.isVideoOrientationSupported {
connection.videoOrientation = Self.currentVideoOrientation
}
} }
#endif #endif
} }
#if os(iOS) #if os(iOS)
private var videoRotationAngle: CGFloat { private var videoRotationAngle: CGFloat {
switch UIDevice.current.orientation { // The interface orientation is authoritative: it is what the user
case .portraitUpsideDown: return 180 // actually sees. The device sensor can claim landscape (or
case .landscapeLeft: return 90 // face-up) while the app stays portrait, and recording with the
case .landscapeRight: return 270 // sensor value rotated portrait circles by 90 degrees.
default: return 0 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 #endif
#if os(iOS) #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 { private func configureCaptureGraph() async throws -> CaptureGraph {
let session = session let session = session
let movieOutput = movieOutput let movieOutput = movieOutput
@@ -885,6 +1064,141 @@ private actor VideoNoteFileInspector {
let duration: TimeInterval 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? { func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000] let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
for delay in retryDelays { for delay in retryDelays {
+88 -11
View File
@@ -61,6 +61,10 @@ struct ChatView: View {
@State private var isNearTimelineBottom = true @State private var isNearTimelineBottom = true
@State private var historyLoadAnchorID: String? @State private var historyLoadAnchorID: String?
@State private var historyTopTriggerArmed = true @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 activeCaptureMode: ComposerCaptureMode?
@State private var preparingCaptureMode: ComposerCaptureMode? @State private var preparingCaptureMode: ComposerCaptureMode?
@State private var captureGestureIsActive = false @State private var captureGestureIsActive = false
@@ -122,6 +126,10 @@ struct ChatView: View {
initialValue: nil initialValue: nil
) )
_hasCompletedInitialScroll = State(initialValue: false) _hasCompletedInitialScroll = State(initialValue: false)
_historyTopTriggerVisible = State(initialValue: false)
_historyLoadEntryCount = State(initialValue: 0)
_historyAutoContinueCount = State(initialValue: 0)
_emptyHistoryRetryCount = State(initialValue: 0)
_isNearTimelineBottom = State(initialValue: true) _isNearTimelineBottom = State(initialValue: true)
_historyLoadAnchorID = State<String?>(initialValue: nil) _historyLoadAnchorID = State<String?>(initialValue: nil)
_historyTopTriggerArmed = State(initialValue: true) _historyTopTriggerArmed = State(initialValue: true)
@@ -365,8 +373,12 @@ struct ChatView: View {
isNearTimelineBottom = true isNearTimelineBottom = true
historyLoadAnchorID = nil historyLoadAnchorID = nil
historyTopTriggerArmed = true historyTopTriggerArmed = true
historyTopTriggerVisible = false
historyAutoContinueCount = 0
emptyHistoryRetryCount = 0
} }
.onDisappear { .onDisappear {
model.endConversationViewing(jid: conversation.jid)
model.endComposerActivity(in: liveConversation) model.endComposerActivity(in: liveConversation)
attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask?.cancel()
attachmentPreviewPresentationTask = nil attachmentPreviewPresentationTask = nil
@@ -487,17 +499,16 @@ struct ChatView: View {
.frame(height: 1) .frame(height: 1)
.accessibilityHidden(true) .accessibilityHidden(true)
.onAppear { .onAppear {
historyTopTriggerVisible = true
guard historyTopTriggerArmed, guard historyTopTriggerArmed,
!model.isLoadingOlderHistory !model.isLoadingOlderHistory
else { return } else { return }
historyTopTriggerArmed = false triggerOlderHistoryLoad()
historyLoadAnchorID =
timelineEntries.first?.id
model
.loadOlderHistoryForSelectedConversation()
} }
.onDisappear { .onDisappear {
historyTopTriggerVisible = false
historyTopTriggerArmed = true historyTopTriggerArmed = true
historyAutoContinueCount = 0
} }
if model.isLoadingOlderHistory { if model.isLoadingOlderHistory {
ProgressView() ProgressView()
@@ -653,14 +664,49 @@ struct ChatView: View {
.onChange(of: model.isLoadingOlderHistory) { .onChange(of: model.isLoadingOlderHistory) {
wasLoading, wasLoading,
isLoading in isLoading in
guard wasLoading, !isLoading, guard wasLoading, !isLoading else { return }
let anchor = historyLoadAnchorID // Restore the scroll position so the newly loaded
else { return } // page appears above the previously visible one. Only
Task { @MainActor in // while the user is still at the top: a slow page must
await Task.yield() // never yank the timeline away from where the user
proxy.scrollTo(anchor, anchor: .top) // 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 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 .onChange(of: timelineEntries.count) { _, _ in
selectedMessageIDs.formIntersection( 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) private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy)
async async
{ {
@@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View {
@StateObject private var recorder = VideoNoteRecorder() @StateObject private var recorder = VideoNoteRecorder()
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var isFinalizing = false @State private var isFinalizing = false
@State private var isSwitchingCamera = false
let onComplete: (VideoNoteRecorder.Recording) -> Void let onComplete: (VideoNoteRecorder.Recording) -> Void
@@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View {
.frame(maxWidth: 420) .frame(maxWidth: 420)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1)) .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) Text(recordingStatus)
.font(.system(.title3, design: .monospaced).weight(.semibold)) .font(.system(.title3, design: .monospaced).weight(.semibold))
@@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View {
#endif #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 { private func formatted(_ duration: TimeInterval) -> String {
let seconds = max(0, Int(duration.rounded(.down))) let seconds = max(0, Int(duration.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60) return String(format: "%d:%02d", seconds / 60, seconds % 60)
+32 -16
View File
@@ -319,7 +319,7 @@ final class XMPPService {
private var mucCursorFallbackArchives: Set<MAMArchiveKey> = [] private var mucCursorFallbackArchives: Set<MAMArchiveKey> = []
private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:] private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:]
private var olderHistoryQueryID: String? private var olderHistoryQueryID: String?
private var olderHistoryCompletion: ((Result<Bool, Error>) -> Void)? private var olderHistoryCompletion: ((Result<OlderHistoryScrollPage, Error>) -> Void)?
private var olderHistoryTimeoutTask: Task<Void, Never>? private var olderHistoryTimeoutTask: Task<Void, Never>?
/// When non-nil, archived mutations produced while applying an interactive /// When non-nil, archived mutations produced while applying an interactive
/// backward-history page are collected here instead of the catch-up /// 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 /// 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 /// 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 /// 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 <first> of the returned page).
func loadOlderHistory( func loadOlderHistory(
conversationJID: String, conversationJID: String,
isGroup: Bool, isGroup: Bool,
before: String?, before: String?,
completion: @escaping (Result<Bool, Error>) -> Void completion: @escaping (Result<OlderHistoryScrollPage, Error>) -> Void
) { ) {
guard let client, client.state == .connected() else { guard let client, client.state == .connected() else {
completion(.failure(LumaXMPPError.notConnected)) completion(.failure(LumaXMPPError.notConnected))
@@ -1015,7 +1016,7 @@ final class XMPPService {
conversationJID: String, conversationJID: String,
isGroup: Bool, isGroup: Bool,
before: String?, before: String?,
completion: @escaping (Result<Bool, Error>) -> Void completion: @escaping (Result<OlderHistoryScrollPage, Error>) -> Void
) { ) {
guard let client, client.state == .connected() else { guard let client, client.state == .connected() else {
completion(.failure(LumaXMPPError.notConnected)) completion(.failure(LumaXMPPError.notConnected))
@@ -1132,7 +1133,18 @@ final class XMPPService {
} }
switch result { switch result {
case .success(let response): case .success(let response):
self.finishOlderHistory(result: .success(!response.complete)) // The next page's anchor is the server's RSM <first>
// 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): case .failure(let error):
// Do not leave the UI in the loading state. A failed // Do not leave the UI in the loading state. A failed
// interactive query is recoverable by the next scroll. // interactive query is recoverable by the next scroll.
@@ -1180,7 +1192,7 @@ final class XMPPService {
/// Every terminal path (success, failure, timeout, disconnect, background) /// Every terminal path (success, failure, timeout, disconnect, background)
/// funnels through here so the UI's "loading older history" spinner can /// funnels through here so the UI's "loading older history" spinner can
/// never be left stuck. /// never be left stuck.
private func finishOlderHistory(result: Result<Bool, Error>) { private func finishOlderHistory(result: Result<OlderHistoryScrollPage, Error>) {
guard olderHistoryCompletion != nil else { return } guard olderHistoryCompletion != nil else { return }
let completion = olderHistoryCompletion let completion = olderHistoryCompletion
olderHistoryCompletion = nil olderHistoryCompletion = nil
@@ -2295,15 +2307,6 @@ final class XMPPService {
// message path. // message path.
return return
default: 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 <body> fallback alongside the // Some clients include a plaintext <body> fallback alongside the
// OMEMO <encrypted> payload. When decryption fails, prefer that // OMEMO <encrypted> payload. When decryption fails, prefer that
// fallback so a readable message is never shown as undecryptable. // fallback so a readable message is never shown as undecryptable.
@@ -2312,6 +2315,15 @@ final class XMPPService {
fingerprint = nil fingerprint = nil
contentMessage = message contentMessage = message
} else { } 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 security = .decryptionFailed
fingerprint = nil fingerprint = nil
contentMessage = nil contentMessage = nil
@@ -2572,13 +2584,17 @@ final class XMPPService {
return return
default: default:
if outgoing { 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( emitGroupEchoIfPossible(
roomJID: roomJID, roomJID: roomJID,
messageID: id, messageID: id,
stanzaID: stanzaID, stanzaID: stanzaID,
senderJID: senderJID senderJID: senderJID
) )
return
} }
if message.body?.isEmpty == false { if message.body?.isEmpty == false {
security = .plaintext security = .plaintext
+2 -3
View File
@@ -2,9 +2,8 @@ import XCTest
@testable import Luma @testable import Luma
final class ArchiveMessageBatchPolicyTests: XCTestCase { final class ArchiveMessageBatchPolicyTests: XCTestCase {
func testMamPagesStaySmallEnoughForInteractiveUI() { func testInteractivePageSize() {
XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.pageSize, 32) XCTAssertEqual(ArchiveMessageBatchPolicy.pageSize, 40)
XCTAssertGreaterThan(ArchiveMessageBatchPolicy.pageSize, 0)
} }
func testStanzasAreDecodedInSmallBatchesPerMainActorSlice() { func testStanzasAreDecodedInSmallBatchesPerMainActorSlice() {
+47
View File
@@ -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 <first> 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)
}
}
+44
View File
@@ -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)"
)
}
}
}