some fixes
This commit is contained in:
1 parent
cf2bda9c54
commit
e28a4043ee
13 files changed
+793
-119
No files matched your search
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -377,6 +384,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -427,6 +435,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -452,6 +461,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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 joiningRoomJIDs: Set<String> = []
|
||||
private var deletedGroupChatJIDs: Set<String> = []
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
private var finalizationTimeoutTask: 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 {
|
||||
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<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()
|
||||
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<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() {
|
||||
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 {
|
||||
|
||||
@@ -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<String?>(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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -319,7 +319,7 @@ final class XMPPService {
|
||||
private var mucCursorFallbackArchives: Set<MAMArchiveKey> = []
|
||||
private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:]
|
||||
private var olderHistoryQueryID: String?
|
||||
private var olderHistoryCompletion: ((Result<Bool, Error>) -> Void)?
|
||||
private var olderHistoryCompletion: ((Result<OlderHistoryScrollPage, Error>) -> Void)?
|
||||
private var olderHistoryTimeoutTask: Task<Void, Never>?
|
||||
/// 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 <first> of the returned page).
|
||||
func loadOlderHistory(
|
||||
conversationJID: String,
|
||||
isGroup: Bool,
|
||||
before: String?,
|
||||
completion: @escaping (Result<Bool, Error>) -> Void
|
||||
completion: @escaping (Result<OlderHistoryScrollPage, Error>) -> 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<Bool, Error>) -> Void
|
||||
completion: @escaping (Result<OlderHistoryScrollPage, Error>) -> 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 <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):
|
||||
// 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<Bool, Error>) {
|
||||
private func finishOlderHistory(result: Result<OlderHistoryScrollPage, Error>) {
|
||||
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 <body> fallback alongside the
|
||||
// OMEMO <encrypted> 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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user