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 */; };
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 */,
);
+12
View File
@@ -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
+45 -11
View File
@@ -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
}
+390 -76
View File
@@ -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 {
+88 -11
View File
@@ -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)
+32 -16
View File
@@ -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 -3
View File
@@ -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() {
+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)"
)
}
}
}