diff --git a/AGENTS.md b/AGENTS.md index 091ce75..d45c64f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,17 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from - `Sources/App/` — iOS/macOS app entry point (`LumaApp.swift`). - `Sources/Shared/` — shared code: - - `Models/` — value types and pure policy enums (`ChatMessage`, - `ArchiveSyncPagination`, `ChatTypingPolicy`, …). - - `UI/` — SwiftUI views and components. + - `Models/` — value types and pure policy enums (`ArchiveSyncPagination`, + `ChatTypingPolicy`, …) plus the SwiftData `@Model` classes + (`ChatMessage`, `Conversation`). + - `UI/` — SwiftUI views; chat list, timeline and forward picker read + SwiftData through `@Query` (`MainChatView`, `ChatView`, + `ForwardMessageView`). - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store. - - `Persistence/` — `ChatArchive` (JSON snapshot), preferences. + - `Persistence/` — `ArchiveStore` (per-account SwiftData container), + `ArchiveMetadataRecord` (durable MAM checkpoint metadata), + `LegacyArchiveImporter` (one-time legacy JSON snapshot migration), + preferences. - `Services/`, `Security/` — media, notifications, credentials. - `Sources/Watch/` — single-target watchOS app. - `Tests/` — XCTest unit tests. diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index 91b0eb7..df37e52 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -9,7 +9,9 @@ 3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks; AES-GCM выполняется CryptoKit. 4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live - сообщения и сохраняет snapshot локально. + сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`, + отдельный `ModelContainer` на аккаунт); список чатов, лента и список + получателей при пересылке читают данные напрямую через `@Query`. 5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS. 6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot, текстовые ответы возвращаются немедленно или через гарантированную очередь, @@ -60,7 +62,7 @@ Signal-криптография не блокирует главный пото автоматически повторяется ограниченное число раз. При уходе приложения с экрана и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы дешифрование истории не конкурировало с камерой и upload. Только после commit -`AppModel` обновляет SwiftUI, локальный snapshot и Apple Watch; незавершённый +`AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый проход при timeout/disconnect отбрасывается целиком. Редактирование реализовано стандартным XEP-0308: исправление получает новый id и @@ -100,8 +102,9 @@ XEP-0424 retraction проходит через тот же plaintext/OMEMO-пу после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM, временно удерживается в ограниченной очереди. Локальное удаление физически -убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы -последующая MAM-синхронизация не добавила её обратно. +удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает +строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор +подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно. Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и @@ -205,6 +208,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры. - APNs provider + XEP-0357 registration; - современный `urn:xmpp:omemo:2` adapter и миграция устройств; - MIX и реакции; -- SQLite/SQLCipher вместо JSON snapshot; +- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится + в SwiftData, а не в JSON snapshot); - PushKit/CallKit для фоновых входящих звонков и групповые звонки; - share extension и notification service extension. diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index 22bfb87..fb3ae1c 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -1,7 +1,7 @@ # Безопасность и модель угроз MVP - Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или - snapshot истории. + локальное хранилище истории. - Проверка сертификата не отключается. Ручной host меняет маршрут подключения, но XMPP domain/JID остаётся исходной идентичностью. - TLS trust оценивается системным Apple Security framework как сертификат @@ -49,22 +49,24 @@ STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже зашифрованный медиапоток. Если Prosody не публикует собственный сервис через XEP-0215, резервный публичный STUN получает запрос на определение адреса. -- На iOS файлы OMEMO-состояния и локальной истории получают data-protection - `completeUntilFirstUserAuthentication`. +- На iOS файл OMEMO-состояния и база локальной истории (SwiftData + `.store`) получают data-protection `completeUntilFirstUserAuthentication`. ## Известные ограничения -- На macOS история и OMEMO state лежат в sandbox Application Support без - дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault - пользователя. Для повышенной модели угроз нужна зашифрованная база. +- На macOS история (SwiftData SQLite в Application Support) и OMEMO state + лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает + учётная запись и FileVault пользователя. Для повышенной модели угроз нужна + зашифрованная база. - Локальная история содержит уже расшифрованные тексты, как и у большинства клиентов. Полное удаление требует выхода с опцией удаления истории. -- «Удалить у меня» удаляет запись только из локального snapshot этого устройства - и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив, - другие собственные устройства или копию собеседника. +- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища + этого устройства и запоминает id против повторной MAM-загрузки; оно не + удаляет серверный архив, другие собственные устройства или копию собеседника. - Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого - JID, а переопределение чата — в локальном snapshot. Эти значения не являются - секретами, но прямо влияют на конфиденциальность следующей отправки. + JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения + не являются секретами, но прямо влияют на конфиденциальность следующей + отправки. - Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется настройками PEP и roster на сервере; не используйте аватар как секретное фото. - Используемая реализация MartinOMEMO поддерживает распространённый legacy diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png new file mode 100644 index 0000000..b3ceff8 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png new file mode 100644 index 0000000..a9b3e5f Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png new file mode 100644 index 0000000..1f780dc Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json index e50e9c1..70b2d6d 100644 --- a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json +++ b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json @@ -199,6 +199,7 @@ "size" : "29x29" }, { + "filename" : "AppIcon-Watch-Notification-45mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", @@ -278,6 +279,7 @@ "subtype" : "44mm" }, { + "filename" : "AppIcon-Watch-QuickLook-45mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", @@ -285,6 +287,7 @@ "subtype" : "45mm" }, { + "filename" : "AppIcon-Watch-QuickLook-49mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 4986bdb..385922a 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -80,6 +80,12 @@ required=( Tests/VideoNoteStopPolicyTests.swift Tests/MessageReplySwipeTests.swift Tests/WatchVoiceMessageTests.swift + Tests/ArchiveStoreTests.swift + Sources/Shared/Models/ChatMessage.swift + Sources/Shared/Models/Conversation.swift + Sources/Shared/Persistence/ArchiveStore.swift + Sources/Shared/Persistence/ArchiveMetadataRecord.swift + Sources/Shared/Persistence/LegacyArchiveImporter.swift Brand/LumaIcon-1024.png Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json Regenerate-Luma-Project.command @@ -257,6 +263,38 @@ grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift echo "The durable archive store must persist the XEP-0313 UID cursor" exit 1 } +if test -f Sources/Shared/Persistence/ChatArchive.swift; then + echo "The legacy JSON ChatArchive actor must stay removed" + exit 1 +fi +grep -q '@Query' Sources/Shared/UI/MainChatView.swift || { + echo "Chat list must be SwiftData @Query-driven" + exit 1 +} +grep -q '@Query' Sources/Shared/UI/ChatView.swift || { + echo "Chat timeline must be SwiftData @Query-driven" + exit 1 +} +grep -q '@Query' Sources/Shared/UI/ForwardMessageView.swift || { + echo "Forward destination list must be SwiftData @Query-driven" + exit 1 +} +grep -q '\.modelContext' Sources/Shared/UI/RootView.swift || { + echo "RootView must inject the SwiftData ModelContext" + exit 1 +} +grep -q 'modelContext.delete(message)' Sources/Shared/Models/AppModel.swift || { + echo "Local message deletion must remove the SwiftData row" + exit 1 +} +grep -q 'purgeLocallyDeletedMessages' Sources/Shared/Persistence/ArchiveStore.swift || { + echo "ArchiveStore must purge locally deleted rows for @Query views" + exit 1 +} +grep -q 'completeUntilFirstUserAuthentication' Sources/Shared/Persistence/ArchiveStore.swift || { + echo "The SwiftData store must keep the data protection attribute" + exit 1 +} grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { echo "MAM reconnects must prefer the durable archive UID over timestamps" exit 1 @@ -358,7 +396,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi echo "Interactive keyboard dismissal is missing" exit 1 } -grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || { +grep -q 'rebuildTimelineEntries' Sources/Shared/UI/ChatView.swift || { echo "Chat rows and day boundaries must use the cached stable timeline" exit 1 } diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index fa25ab2..dcbf1ff 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -1,6 +1,7 @@ import AVFoundation import Combine import Foundation +import SwiftData import UniformTypeIdentifiers import WebRTC @@ -73,6 +74,8 @@ final class AppModel: ObservableObject { private let mediaPreviewProcessor = MediaPreviewProcessor() private let mediaFileIO = MediaFileIO() private var store: ArchiveStore? + private var storeAccountJID: String? + private var fallbackContext: ModelContext? private var bootstrapTask: Task? private var persistTask: Task? private var watchSyncTask: Task? @@ -257,9 +260,37 @@ final class AppModel: ObservableObject { return entries } + /// The `ModelContext` injected into the SwiftUI environment so views + /// can read conversations and messages through `@Query`. Falls back to + /// an in-memory context when the on-disk store cannot be opened, keeping + /// the session usable without persistence. + var modelContext: ModelContext { + if let store { return store.context } + if let fallbackContext { return fallbackContext } + let schema = Schema([ + Conversation.self, + ChatMessage.self, + ArchiveMetadata.self, + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: schema, configurations: [configuration]) + let context = ModelContext(container) + fallbackContext = context + return context + } + + /// Opens the per-account SwiftData store before `account` is published so + /// the `@Query`-backed screens never render without a usable context. + private func prepareStore(for jid: String) { + guard store == nil || storeAccountJID != jid else { return } + store = try? ArchiveStore(accountJID: jid) + storeAccountJID = jid + } + func bootstrap() async { guard !RuntimeEnvironment.isRunningTests else { return } guard let saved = preferences.load() else { return } + prepareStore(for: saved.normalizedJID) account = saved globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID) typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID) @@ -270,6 +301,7 @@ final class AppModel: ObservableObject { guard let storedPassword = try credentials.password(for: saved.normalizedJID) else { account = nil store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -281,6 +313,7 @@ final class AppModel: ObservableObject { } catch { account = nil store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -323,6 +356,7 @@ final class AppModel: ObservableObject { rosterContactJIDs = [] resetMediaSendActivity() resetMediaPreviews() + prepareStore(for: account.normalizedJID) self.account = account globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID) typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID) @@ -341,6 +375,7 @@ final class AppModel: ObservableObject { await xmpp.disconnect() self.account = nil store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -364,6 +399,7 @@ final class AppModel: ObservableObject { watchSyncTask?.cancel() watchSyncTask = nil store = nil + fallbackContext = nil account = nil conversations = [] rosterContactJIDs = [] @@ -884,6 +920,7 @@ final class AppModel: ObservableObject { let affectedConversations = Set(selected.map(\.conversationID)) for message in selected { + modelContext.delete(message) locallyDeletedMessageIDs.insert( Self.localDeletionKey( messageID: message.clientID, @@ -2116,7 +2153,7 @@ final class AppModel: ObservableObject { } } else { let conversation = Conversation(jid: normalized, displayName: name) - store?.context.insert(conversation) + modelContext.insert(conversation) conversations.append(conversation) } if conversations.first(where: { $0.jid == normalized })?.isGroup != true { @@ -2169,7 +2206,7 @@ final class AppModel: ObservableObject { shouldAutojoin: shouldAutojoin, invitedBy: invitedBy ) - store?.context.insert(conversation) + modelContext.insert(conversation) conversations.append(conversation) } if !isApplyingArchiveBatch { @@ -2555,8 +2592,8 @@ final class AppModel: ObservableObject { // `merged` is the freshly-created incoming message; replace the // previously managed object with it so SwiftData does not keep // an orphaned duplicate of the same clientID. - store?.context.delete(previous) - store?.context.insert(merged) + modelContext.delete(previous) + modelContext.insert(merged) } if let stanzaID = merged.stanzaID { messageIndexByStanzaKey[ @@ -2575,7 +2612,7 @@ final class AppModel: ObservableObject { updateConversationPreview(for: merged, incrementUnread: false) return false } - store?.context.insert(message) + modelContext.insert(message) messages.append(message) let insertedIndex = messages.index(before: messages.endIndex) messageIndexByStorageKey[ @@ -2757,8 +2794,9 @@ final class AppModel: ObservableObject { private func loadArchive(for jid: String) async { resetArchiveBatchState() resetMediaPreviews() - guard let store = try? ArchiveStore(accountJID: jid) else { - self.store = nil + prepareStore(for: jid) + guard let store else { + fallbackContext = nil return } self.store = store @@ -3058,7 +3096,7 @@ final class AppModel: ObservableObject { } private static func localDeletionKey(messageID: String, conversationID: String) -> String { - conversationID.lowercased() + "\u{1F}" + messageID + ArchiveStore.deletionKey(messageID: messageID, conversationID: conversationID) } } diff --git a/Sources/Shared/Persistence/ArchiveStore.swift b/Sources/Shared/Persistence/ArchiveStore.swift index 5b58a5d..eb11a2d 100644 --- a/Sources/Shared/Persistence/ArchiveStore.swift +++ b/Sources/Shared/Persistence/ArchiveStore.swift @@ -43,8 +43,10 @@ final class ArchiveStore { container = try ModelContainer(for: schema, configurations: [configuration]) context = ModelContext(container) context.autosaveEnabled = false + applyDataProtection() migrateLegacyJSONIfNeeded() + purgeLocallyDeletedMessages() } func load() -> Loaded { @@ -86,6 +88,7 @@ final class ArchiveStore { MAMCheckpointEntry(key: $0.key, checkpoint: $0.value) } try context.save() + applyDataProtection() } func erase() throws { @@ -121,11 +124,58 @@ final class ArchiveStore { metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor metadata.mamCheckpoints = imported.mamCheckpoints - try? context.save() + do { + try context.save() + } catch { + // The imported rows stay pending in memory so this session keeps + // the history, but the legacy JSON remains on disk as the only + // durable copy until a save actually succeeds. + return + } + applyDataProtection() try? FileManager.default.removeItem(at: legacyJSONURL) } - private static func stableHash(_ value: String) -> String { + /// Removes rows that older builds only marked as locally deleted (the + /// legacy snapshot filtered them at load time). `@Query`-based views read + /// the store directly, so these rows must physically disappear. + func purgeLocallyDeletedMessages() { + let metadata = fetchMetadata() + let deletedKeys = Set(metadata.locallyDeletedMessageIDs) + guard !deletedKeys.isEmpty else { return } + let messages = (try? context.fetch(FetchDescriptor())) ?? [] + var removedAny = false + for message in messages { + let key = Self.deletionKey( + messageID: message.clientID, + conversationID: message.conversationID + ) + guard deletedKeys.contains(key) else { continue } + context.delete(message) + removedAny = true + } + if removedAny { + try? context.save() + applyDataProtection() + } + } + + /// Key format shared with `AppModel.localDeletionKey`: + /// `\u{1F}`. + static func deletionKey(messageID: String, conversationID: String) -> String { + conversationID.lowercased() + "\u{1F}" + messageID + } + + private func applyDataProtection() { +#if os(iOS) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: storeURL.path + ) +#endif + } + + static func stableHash(_ value: String) -> String { let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in (partial ^ UInt64(byte)) &* 1_099_511_628_211 } diff --git a/Sources/Shared/Persistence/LegacyArchiveImporter.swift b/Sources/Shared/Persistence/LegacyArchiveImporter.swift index 7e9c851..bed14e1 100644 --- a/Sources/Shared/Persistence/LegacyArchiveImporter.swift +++ b/Sources/Shared/Persistence/LegacyArchiveImporter.swift @@ -2,6 +2,9 @@ import Foundation /// Reads the legacy JSON snapshot and converts it into SwiftData `@Model` /// objects once, so existing history survives the migration from the JSON file. +/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`: +/// every field that was ever optional or added later falls back to a default, +/// so snapshots written by older schema versions still import. enum LegacyArchiveImporter { struct LegacyConversation: Codable { enum Kind: String, Codable { case direct, group } @@ -20,6 +23,80 @@ enum LegacyArchiveImporter { var shouldAutojoin: Bool var occupantCount: Int var invitedBy: String? + + init( + id: String, + jid: String, + displayName: String, + lastMessage: String, + lastActivity: Date, + unreadCount: Int, + isOnline: Bool, + isPinned: Bool, + encryptionPreference: EncryptionPreference, + kind: Kind, + groupNickname: String? = nil, + isGroupJoined: Bool, + shouldAutojoin: Bool, + occupantCount: Int, + invitedBy: String? = nil + ) { + self.id = id + self.jid = jid + self.displayName = displayName + self.lastMessage = lastMessage + self.lastActivity = lastActivity + self.unreadCount = unreadCount + self.isOnline = isOnline + self.isPinned = isPinned + self.encryptionPreference = encryptionPreference + self.kind = kind + self.groupNickname = groupNickname + self.isGroupJoined = isGroupJoined + self.shouldAutojoin = shouldAutojoin + self.occupantCount = occupantCount + self.invitedBy = invitedBy + } + + private enum CodingKeys: String, CodingKey { + case id + case jid + case displayName + case lastMessage + case lastActivity + case unreadCount + case isOnline + case isPinned + case encryptionPreference + case kind + case groupNickname + case isGroupJoined + case shouldAutojoin + case occupantCount + case invitedBy + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + jid = try values.decode(String.self, forKey: .jid) + id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased() + displayName = try values.decode(String.self, forKey: .displayName) + lastMessage = try values.decode(String.self, forKey: .lastMessage) + lastActivity = try values.decode(Date.self, forKey: .lastActivity) + unreadCount = try values.decode(Int.self, forKey: .unreadCount) + isOnline = try values.decode(Bool.self, forKey: .isOnline) + isPinned = try values.decode(Bool.self, forKey: .isPinned) + encryptionPreference = try values.decodeIfPresent( + EncryptionPreference.self, + forKey: .encryptionPreference + ) ?? .inheritGlobal + kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct + groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname) + isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false + shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false + occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0) + invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy) + } } struct LegacyMessage: Codable { @@ -56,9 +133,133 @@ enum LegacyArchiveImporter { var isGroupMessage: Bool var callHistory: CallHistoryMetadata? var reactions: [MessageReaction] + + init( + id: String, + conversationID: String, + senderJID: String, + body: String, + timestamp: Date, + direction: Direction, + delivery: Delivery, + security: Security, + kind: Kind, + remoteAttachmentURL: String? = nil, + localFilename: String? = nil, + mimeType: String? = nil, + duration: TimeInterval? = nil, + byteCount: Int? = nil, + encryptionFingerprint: String? = nil, + editedAt: Date? = nil, + replyToID: String? = nil, + replyToJID: String? = nil, + replyPreview: String? = nil, + forwardedFrom: String? = nil, + retractedAt: Date? = nil, + originID: String? = nil, + stanzaID: String? = nil, + senderDisplayName: String? = nil, + isGroupMessage: Bool = false, + callHistory: CallHistoryMetadata? = nil, + reactions: [MessageReaction] = [] + ) { + self.id = id + self.conversationID = conversationID + self.senderJID = senderJID + self.body = body + self.timestamp = timestamp + self.direction = direction + self.delivery = delivery + self.security = security + self.kind = kind + self.remoteAttachmentURL = remoteAttachmentURL + self.localFilename = localFilename + self.mimeType = mimeType + self.duration = duration + self.byteCount = byteCount + self.encryptionFingerprint = encryptionFingerprint + self.editedAt = editedAt + self.replyToID = replyToID + self.replyToJID = replyToJID + self.replyPreview = replyPreview + self.forwardedFrom = forwardedFrom + self.retractedAt = retractedAt + self.originID = originID + self.stanzaID = stanzaID + self.senderDisplayName = senderDisplayName + self.isGroupMessage = isGroupMessage + self.callHistory = callHistory + self.reactions = reactions + } + + private enum CodingKeys: String, CodingKey { + case id + case conversationID + case senderJID + case body + case timestamp + case direction + case delivery + case security + case kind + case remoteAttachmentURL + case localFilename + case mimeType + case duration + case byteCount + case encryptionFingerprint + case editedAt + case replyToID + case replyToJID + case replyPreview + case forwardedFrom + case retractedAt + case originID + case stanzaID + case senderDisplayName + case isGroupMessage + case callHistory + case reactions + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + conversationID = try values.decode(String.self, forKey: .conversationID).lowercased() + senderJID = try values.decode(String.self, forKey: .senderJID) + body = try values.decode(String.self, forKey: .body) + timestamp = try values.decode(Date.self, forKey: .timestamp) + direction = try values.decode(Direction.self, forKey: .direction) + delivery = try values.decode(Delivery.self, forKey: .delivery) + security = try values.decode(Security.self, forKey: .security) + kind = try values.decode(Kind.self, forKey: .kind) + remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL) + localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename) + mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType) + duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration) + byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount) + encryptionFingerprint = try values.decodeIfPresent( + String.self, + forKey: .encryptionFingerprint + ) + editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt) + replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID) + replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID) + replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview) + forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom) + retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt) + originID = try values.decodeIfPresent(String.self, forKey: .originID) + stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID) + senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName) + isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false + callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory) + reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? [] + } } struct Snapshot: Codable { + static let currentSchemaVersion = 6 + var schemaVersion: Int var conversations: [LegacyConversation] var messages: [LegacyMessage] @@ -67,6 +268,62 @@ enum LegacyArchiveImporter { var lastSuccessfulMAMSync: Date? var lastSuccessfulMAMCursor: String? var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] + + init( + conversations: [LegacyConversation], + messages: [LegacyMessage], + locallyDeletedMessageIDs: Set = [], + rosterContactJIDs: Set = [], + lastSuccessfulMAMSync: Date? = nil, + lastSuccessfulMAMCursor: String? = nil, + mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] + ) { + self.schemaVersion = Self.currentSchemaVersion + self.conversations = conversations + self.messages = messages + self.locallyDeletedMessageIDs = locallyDeletedMessageIDs + self.rosterContactJIDs = rosterContactJIDs + self.lastSuccessfulMAMSync = lastSuccessfulMAMSync + self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor + self.mamCheckpoints = mamCheckpoints + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case conversations + case messages + case locallyDeletedMessageIDs + case rosterContactJIDs + case lastSuccessfulMAMSync + case lastSuccessfulMAMCursor + case mamCheckpoints + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + conversations = try values.decode([LegacyConversation].self, forKey: .conversations) + messages = try values.decode([LegacyMessage].self, forKey: .messages) + locallyDeletedMessageIDs = try values.decodeIfPresent( + Set.self, + forKey: .locallyDeletedMessageIDs + ) ?? [] + rosterContactJIDs = try values.decodeIfPresent( + Set.self, + forKey: .rosterContactJIDs + ) ?? [] + lastSuccessfulMAMSync = try values.decodeIfPresent( + Date.self, + forKey: .lastSuccessfulMAMSync + ) + lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor( + try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor) + ) + mamCheckpoints = try values.decodeIfPresent( + [MAMArchiveKey: MAMArchiveCheckpoint].self, + forKey: .mamCheckpoints + ) ?? [:] + } } struct Imported { diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 58fab13..898b2bd 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -2,6 +2,7 @@ import CoreTransferable import Foundation import ImageIO import PhotosUI +import SwiftData import SwiftUI import UniformTypeIdentifiers @@ -67,6 +68,65 @@ struct ChatView: View { @State private var captureAttemptID = UUID() @State private var captureStartTask: Task? @State private var captureSuspendsArchiveSync = false + @State private var timelineEntries: [ChatTimelineEntry] = [] + + /// Messages of this conversation straight from the SwiftData store, + /// ordered like `AppModel.selectedMessages`: timestamp, then clientID. + @Query private var messages: [ChatMessage] + + init(model: AppModel, conversation: Conversation) { + self.model = model + self.conversation = conversation + let conversationJID = conversation.jid + _messages = Query( + filter: #Predicate { message in + message.conversationID == conversationJID + }, + sort: [ + SortDescriptor(\ChatMessage.timestamp, order: .forward), + SortDescriptor(\ChatMessage.clientID, order: .forward), + ] + ) + _audioRecorder = StateObject(wrappedValue: AudioMessageRecorder()) + _isComposerFocused = FocusState() + _draft = State(initialValue: "") + _showingFileImporter = State(initialValue: false) + _fileImportMode = State(initialValue: FileImportMode.files) + _showingEncryption = State(initialValue: false) + _showingVideoNoteRecorder = State(initialValue: false) + _videoNoteIsSending = State(initialValue: false) + _showingMediaPicker = State(initialValue: false) + _showingPhotoCamera = State(initialValue: false) + _photoCameraIsPreparingResult = State(initialValue: false) + _showingLocationPicker = State(initialValue: false) + _showingGroupInfo = State(initialValue: false) + _showingAttachmentPreview = State(initialValue: false) + _attachmentPreviewPresentationPending = State(initialValue: false) + _attachmentPreviewPresentationTask = State?>(initialValue: nil) + _pickedMediaItems = State<[PhotosPickerItem]>(initialValue: []) + _attachmentDrafts = State<[AttachmentDraft]>(initialValue: []) + _isPreparingAttachments = State(initialValue: false) + _editingMessageID = State(initialValue: nil) + _replyingToMessageID = State(initialValue: nil) + _forwardingSelection = State(initialValue: nil) + _selectedMessageIDs = State>(initialValue: []) + _destructiveAction = State(initialValue: nil) + _replyThreadSelection = State(initialValue: nil) + _emojiPickerPresentation = State(initialValue: nil) + _hasCompletedInitialScroll = State(initialValue: false) + _isNearTimelineBottom = State(initialValue: true) + _historyLoadAnchorID = State(initialValue: nil) + _historyTopTriggerArmed = State(initialValue: true) + _activeCaptureMode = State(initialValue: nil) + _preparingCaptureMode = State(initialValue: nil) + _captureGestureIsActive = State(initialValue: false) + _captureIsLocked = State(initialValue: false) + _captureDragTranslation = State(initialValue: CGSize.zero) + _captureAttemptID = State(initialValue: UUID()) + _captureStartTask = State?>(initialValue: nil) + _captureSuspendsArchiveSync = State(initialValue: false) + _timelineEntries = State<[ChatTimelineEntry]>(initialValue: []) + } var body: some View { VStack(spacing: 0) { @@ -352,13 +412,16 @@ struct ChatView: View { model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) } } + private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) { + timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages) + } + private var isSelectingMessages: Bool { !selectedMessageIDs.isEmpty } private var messageTimeline: some View { ScrollViewReader { proxy in - let timelineEntries = model.selectedTimelineEntries GeometryReader { viewport in ZStack(alignment: .bottomTrailing) { ScrollView { @@ -468,6 +531,12 @@ struct ChatView: View { .scrollDismissesKeyboard(.interactively) #endif .coordinateSpace(name: Self.timelineCoordinateSpace) + .onAppear { + rebuildTimelineEntries() + } + .onChange(of: messages) { _, newMessages in + rebuildTimelineEntries(from: newMessages) + } #if os(macOS) .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in updateTimelineBottomProximity( diff --git a/Sources/Shared/UI/ForwardMessageView.swift b/Sources/Shared/UI/ForwardMessageView.swift index c285ace..0a4cc8d 100644 --- a/Sources/Shared/UI/ForwardMessageView.swift +++ b/Sources/Shared/UI/ForwardMessageView.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI @MainActor @@ -10,6 +11,9 @@ struct ForwardMessageView: View { @State private var destinationJID = "" @State private var isForwarding = false + /// SwiftData-backed destination list, ordered like the main chat list. + @Query private var conversations: [Conversation] + init( model: AppModel, messages: [ChatMessage], @@ -18,6 +22,11 @@ struct ForwardMessageView: View { _model = ObservedObject(wrappedValue: model) self.messages = messages.filter(\.canBeForwarded) self.onComplete = onComplete + _conversations = Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse), + ] + ) } var body: some View { @@ -39,9 +48,9 @@ struct ForwardMessageView: View { } } - if !model.conversations.isEmpty { + if !orderedConversations.isEmpty { Section("Чаты") { - ForEach(model.conversations) { conversation in + ForEach(orderedConversations) { conversation in Button { forward(to: conversation.jid) } label: { @@ -105,6 +114,14 @@ struct ForwardMessageView: View { #endif } + /// Pinned-first ordering, matching the main chat list. + private var orderedConversations: [Conversation] { + conversations.sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + return lhs.lastActivity > rhs.lastActivity + } + } + private func forward(to jid: String) { guard !isForwarding, !messages.isEmpty else { return } isForwarding = true diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift index 838c303..4c0bc8f 100644 --- a/Sources/Shared/UI/MainChatView.swift +++ b/Sources/Shared/UI/MainChatView.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftData import SwiftUI struct MainChatView: View { @@ -21,27 +22,45 @@ struct MainChatView: View { @State private var showingNewGroup = false @State private var showingSettings = false + /// SwiftData-backed chat list. The query orders by last activity; + /// `filteredConversations` applies the pinned-first ordering that matches + /// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning + /// cannot be a `SortDescriptor`). + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse), + ] + ) + private var conversations: [Conversation] + private var filteredConversations: [Conversation] { - guard !searchText.isEmpty else { return model.conversations } - return model.conversations.filter(matchesSearch) + let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch) + return source.sorted(by: conversationSort) + } + + private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + == .orderedAscending } private var filteredRosterContacts: [Conversation] { - model.conversations + conversations .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } .filter { searchText.isEmpty || matchesSearch($0) } .sorted(by: contactSort) } private var filteredGroupContacts: [Conversation] { - model.conversations + conversations .filter { $0.isGroup } .filter { searchText.isEmpty || matchesSearch($0) } .sorted(by: contactSort) } private var hasStoredContacts: Bool { - !model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup } + !model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup } } var body: some View { diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index 269b465..66fc817 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -1,4 +1,5 @@ import QuickLook +import SwiftData import SwiftUI struct RootView: View { @@ -11,6 +12,7 @@ struct RootView: View { LoginView(model: model) } else { MainChatView(model: model) + .environment(\.modelContext, model.modelContext) } } diff --git a/Tests/ArchiveStoreTests.swift b/Tests/ArchiveStoreTests.swift index 6a65de3..3f9241e 100644 --- a/Tests/ArchiveStoreTests.swift +++ b/Tests/ArchiveStoreTests.swift @@ -45,7 +45,6 @@ final class ArchiveStoreTests: XCTestCase { func testLegacyImportMapsConversationAndMessage() throws { let jid = "test-\(UUID().uuidString)@example.org" let legacy = LegacyArchiveImporter.Snapshot( - schemaVersion: 1, conversations: [ LegacyArchiveImporter.LegacyConversation( id: "bob@example.org", @@ -75,32 +74,9 @@ final class ArchiveStoreTests: XCTestCase { direction: .outgoing, delivery: .sent, security: .omemo, - kind: .text, - remoteAttachmentURL: nil, - localFilename: nil, - mimeType: nil, - duration: nil, - byteCount: nil, - encryptionFingerprint: nil, - editedAt: nil, - replyToID: nil, - replyToJID: nil, - replyPreview: nil, - forwardedFrom: nil, - retractedAt: nil, - originID: nil, - stanzaID: nil, - senderDisplayName: nil, - isGroupMessage: false, - callHistory: nil, - reactions: [] + kind: .text ) - ], - locallyDeletedMessageIDs: [], - rosterContactJIDs: [], - lastSuccessfulMAMSync: nil, - lastSuccessfulMAMCursor: nil, - mamCheckpoints: [:] + ] ) let encoder = JSONEncoder() @@ -117,4 +93,111 @@ final class ArchiveStoreTests: XCTestCase { XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"]) XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org") } + + func testLegacyImportToleratesMissingOptionalFields() throws { + // Snapshots written by older schema versions lack fields that were + // added later (reactions, isGroupMessage, rosterContactJIDs, ...). + // The importer must decode them with defaults, exactly like the + // removed ChatArchive did. + let raw: [String: Any] = [ + "schemaVersion": 2, + "conversations": [ + [ + "id": "bob@example.org", + "jid": "bob@example.org", + "displayName": "Bob", + "lastMessage": "", + "lastActivity": 1_700_000_000_000.0, + "unreadCount": 0, + "isOnline": false, + "isPinned": false, + ] + ], + "messages": [ + [ + "id": "msg-1", + "conversationID": "bob@example.org", + "senderJID": "alice@example.org", + "body": "Hello", + "timestamp": 1_700_000_000_000.0, + "direction": "outgoing", + "delivery": "sent", + "security": "omemo", + "kind": "text", + ] + ], + ] + let data = try JSONSerialization.data(withJSONObject: raw) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-old-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try data.write(to: url) + + let imported = try LegacyArchiveImporter.importIfNeeded(from: url) + XCTAssertNotNil(imported) + XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"]) + XCTAssertEqual(imported?.conversations.first?.kind, .direct) + XCTAssertEqual(imported?.conversations.first?.isGroupJoined, false) + XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"]) + XCTAssertEqual(imported?.messages.first?.isGroupMessage, false) + XCTAssertEqual(imported?.messages.first?.reactions ?? [], []) + XCTAssertEqual(imported?.rosterContactJIDs ?? [], []) + XCTAssertEqual(imported?.mamCheckpoints ?? [], []) + XCTAssertNil(imported?.lastSuccessfulMAMSync) + } + + func testLocallyDeletedRowsArePurgedFromTheStore() throws { + // @Query views read the store directly, so messages that were only + // marked as locally deleted must be physically removed on open. + let jid = "test-\(UUID().uuidString)@example.org" + let store = try ArchiveStore(accountJID: jid) + defer { try? store.erase() } + + let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") + let message = ChatMessage( + conversationID: conversation.jid, + senderJID: jid, + body: "Will be deleted", + direction: .outgoing, + delivery: .sent, + security: .omemo + ) + store.context.insert(conversation) + store.context.insert(message) + let deletionKey = ArchiveStore.deletionKey( + messageID: message.clientID, + conversationID: conversation.jid + ) + try store.save( + locallyDeletedMessageIDs: [deletionKey], + rosterContactJIDs: [], + lastSuccessfulMAMSync: nil, + lastSuccessfulMAMCursor: nil, + mamCheckpoints: [:] + ) + + store.purgeLocallyDeletedMessages() + let loaded = store.load() + XCTAssertTrue(loaded.messages.isEmpty) + XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set([deletionKey])) + } + + func testCorruptLegacyJSONIsKeptForRetry() throws { + let jid = "test-\(UUID().uuidString)@example.org" + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("Luma/Accounts", isDirectory: true) + let legacyURL = directory.appendingPathComponent("\(ArchiveStore.stableHash(jid)).json") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: legacyURL) + defer { try? FileManager.default.removeItem(at: legacyURL) } + + let store = try ArchiveStore(accountJID: jid) + defer { try? store.erase() } + XCTAssertTrue(store.load().conversations.isEmpty) + // The unreadable snapshot must stay on disk instead of being deleted + // together with the user's history. + XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path)) + } }