finish SwiftData migration with @Query views
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

- Drive chat list, timeline and forward picker from SwiftData @Query
  instead of AppModel's in-memory arrays; inject the per-account
  ModelContext from RootView with an in-memory fallback.
- Physically delete locally deleted messages (context.delete) and
  purge rows marked deleted by older builds on store open, so @Query
  views never resurrect them.
- Delete the legacy JSON snapshot only after a successful import save
  and restore the completeUntilFirstUserAuthentication data protection
  attribute on the store file.
- Mirror the old ChatArchive tolerant decoding in LegacyArchiveImporter
  so snapshots from older schema versions (missing reactions,
  isGroupMessage, roster fields) still import.
- Cover the new behaviour in ArchiveStoreTests and guard it with new
  verify.sh invariants; update AGENTS.md, ARCHITECTURE.md and
  SECURITY.md.
This commit is contained in:
wt committed 2026-08-29 00:57:08 +07:00
1 parent 277d8654c3
commit 2884810ec4
16 files changed
+653 -65

No files matched your search

+10 -4
View File
@@ -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/App/` — iOS/macOS app entry point (`LumaApp.swift`).
- `Sources/Shared/` — shared code: - `Sources/Shared/` — shared code:
- `Models/` — value types and pure policy enums (`ChatMessage`, - `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
`ArchiveSyncPagination`, `ChatTypingPolicy`, …). `ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
- `UI/` — SwiftUI views and components. (`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. - `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. - `Services/`, `Security/` — media, notifications, credentials.
- `Sources/Watch/` — single-target watchOS app. - `Sources/Watch/` — single-target watchOS app.
- `Tests/` — XCTest unit tests. - `Tests/` — XCTest unit tests.
+9 -5
View File
@@ -9,7 +9,9 @@
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks; 3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
AES-GCM выполняется CryptoKit. AES-GCM выполняется CryptoKit.
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live 4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
сообщения и сохраняет snapshot локально. сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
отдельный `ModelContainer` на аккаунт); список чатов, лента и список
получателей при пересылке читают данные напрямую через `@Query`.
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS. 5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot, 6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
текстовые ответы возвращаются немедленно или через гарантированную очередь, текстовые ответы возвращаются немедленно или через гарантированную очередь,
@@ -60,7 +62,7 @@ Signal-криптография не блокирует главный пото
автоматически повторяется ограниченное число раз. При уходе приложения с экрана автоматически повторяется ограниченное число раз. При уходе приложения с экрана
и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы
дешифрование истории не конкурировало с камерой и upload. Только после commit дешифрование истории не конкурировало с камерой и upload. Только после commit
`AppModel` обновляет SwiftUI, локальный snapshot и Apple Watch; незавершённый `AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый
проход при timeout/disconnect отбрасывается целиком. проход при timeout/disconnect отбрасывается целиком.
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
@@ -100,8 +102,9 @@ XEP-0424 retraction проходит через тот же plaintext/OMEMO-пу
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM, редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
временно удерживается в ограниченной очереди. Локальное удаление физически временно удерживается в ограниченной очереди. Локальное удаление физически
убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
последующая MAM-синхронизация не добавила её обратно. строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно.
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
@@ -205,6 +208,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
- APNs provider + XEP-0357 registration; - APNs provider + XEP-0357 registration;
- современный `urn:xmpp:omemo:2` adapter и миграция устройств; - современный `urn:xmpp:omemo:2` adapter и миграция устройств;
- MIX и реакции; - MIX и реакции;
- SQLite/SQLCipher вместо JSON snapshot; - шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
в SwiftData, а не в JSON snapshot);
- PushKit/CallKit для фоновых входящих звонков и групповые звонки; - PushKit/CallKit для фоновых входящих звонков и групповые звонки;
- share extension и notification service extension. - share extension и notification service extension.
+13 -11
View File
@@ -1,7 +1,7 @@
# Безопасность и модель угроз MVP # Безопасность и модель угроз MVP
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или - Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
snapshot истории. локальное хранилище истории.
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения, - Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
но XMPP domain/JID остаётся исходной идентичностью. но XMPP domain/JID остаётся исходной идентичностью.
- TLS trust оценивается системным Apple Security framework как сертификат - TLS trust оценивается системным Apple Security framework как сертификат
@@ -49,22 +49,24 @@
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
XEP-0215, резервный публичный STUN получает запрос на определение адреса. XEP-0215, резервный публичный STUN получает запрос на определение адреса.
- На iOS файлы OMEMO-состояния и локальной истории получают data-protection - На iOS файл OMEMO-состояния и база локальной истории (SwiftData
`completeUntilFirstUserAuthentication`. `.store`) получают data-protection `completeUntilFirstUserAuthentication`.
## Известные ограничения ## Известные ограничения
- На macOS история и OMEMO state лежат в sandbox Application Support без - На macOS история (SwiftData SQLite в Application Support) и OMEMO state
дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает
пользователя. Для повышенной модели угроз нужна зашифрованная база. учётная запись и FileVault пользователя. Для повышенной модели угроз нужна
зашифрованная база.
- Локальная история содержит уже расшифрованные тексты, как и у большинства - Локальная история содержит уже расшифрованные тексты, как и у большинства
клиентов. Полное удаление требует выхода с опцией удаления истории. клиентов. Полное удаление требует выхода с опцией удаления истории.
- «Удалить у меня» удаляет запись только из локального snapshot этого устройства - «Удалить у меня» удаляет запись только из локального SwiftData-хранилища
и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив, этого устройства и запоминает id против повторной MAM-загрузки; оно не
другие собственные устройства или копию собеседника. удаляет серверный архив, другие собственные устройства или копию собеседника.
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого - Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
JID, а переопределение чата — в локальном snapshot. Эти значения не являются JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения
секретами, но прямо влияют на конфиденциальность следующей отправки. не являются секретами, но прямо влияют на конфиденциальность следующей
отправки.
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется - Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
настройками PEP и roster на сервере; не используйте аватар как секретное фото. настройками PEP и roster на сервере; не используйте аватар как секретное фото.
- Используемая реализация MartinOMEMO поддерживает распространённый legacy - Используемая реализация MartinOMEMO поддерживает распространённый legacy
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -199,6 +199,7 @@
"size" : "29x29" "size" : "29x29"
}, },
{ {
"filename" : "AppIcon-Watch-Notification-45mm@2x.png",
"idiom" : "watch", "idiom" : "watch",
"role" : "notificationCenter", "role" : "notificationCenter",
"scale" : "2x", "scale" : "2x",
@@ -278,6 +279,7 @@
"subtype" : "44mm" "subtype" : "44mm"
}, },
{ {
"filename" : "AppIcon-Watch-QuickLook-45mm@2x.png",
"idiom" : "watch", "idiom" : "watch",
"role" : "quickLook", "role" : "quickLook",
"scale" : "2x", "scale" : "2x",
@@ -285,6 +287,7 @@
"subtype" : "45mm" "subtype" : "45mm"
}, },
{ {
"filename" : "AppIcon-Watch-QuickLook-49mm@2x.png",
"idiom" : "watch", "idiom" : "watch",
"role" : "quickLook", "role" : "quickLook",
"scale" : "2x", "scale" : "2x",
+39 -1
View File
@@ -80,6 +80,12 @@ required=(
Tests/VideoNoteStopPolicyTests.swift Tests/VideoNoteStopPolicyTests.swift
Tests/MessageReplySwipeTests.swift Tests/MessageReplySwipeTests.swift
Tests/WatchVoiceMessageTests.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 Brand/LumaIcon-1024.png
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
Regenerate-Luma-Project.command 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" echo "The durable archive store must persist the XEP-0313 UID cursor"
exit 1 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 || { grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
echo "MAM reconnects must prefer the durable archive UID over timestamps" echo "MAM reconnects must prefer the durable archive UID over timestamps"
exit 1 exit 1
@@ -358,7 +396,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi
echo "Interactive keyboard dismissal is missing" echo "Interactive keyboard dismissal is missing"
exit 1 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" echo "Chat rows and day boundaries must use the cached stable timeline"
exit 1 exit 1
} }
+46 -8
View File
@@ -1,6 +1,7 @@
import AVFoundation import AVFoundation
import Combine import Combine
import Foundation import Foundation
import SwiftData
import UniformTypeIdentifiers import UniformTypeIdentifiers
import WebRTC import WebRTC
@@ -73,6 +74,8 @@ final class AppModel: ObservableObject {
private let mediaPreviewProcessor = MediaPreviewProcessor() private let mediaPreviewProcessor = MediaPreviewProcessor()
private let mediaFileIO = MediaFileIO() private let mediaFileIO = MediaFileIO()
private var store: ArchiveStore? private var store: ArchiveStore?
private var storeAccountJID: String?
private var fallbackContext: ModelContext?
private var bootstrapTask: Task<Void, Never>? private var bootstrapTask: Task<Void, Never>?
private var persistTask: Task<Void, Never>? private var persistTask: Task<Void, Never>?
private var watchSyncTask: Task<Void, Never>? private var watchSyncTask: Task<Void, Never>?
@@ -257,9 +260,37 @@ final class AppModel: ObservableObject {
return entries 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 { func bootstrap() async {
guard !RuntimeEnvironment.isRunningTests else { return } guard !RuntimeEnvironment.isRunningTests else { return }
guard let saved = preferences.load() else { return } guard let saved = preferences.load() else { return }
prepareStore(for: saved.normalizedJID)
account = saved account = saved
globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID) globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(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 { guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
account = nil account = nil
store = nil store = nil
fallbackContext = nil
conversations = [] conversations = []
rosterContactJIDs = [] rosterContactJIDs = []
messages = [] messages = []
@@ -281,6 +313,7 @@ final class AppModel: ObservableObject {
} catch { } catch {
account = nil account = nil
store = nil store = nil
fallbackContext = nil
conversations = [] conversations = []
rosterContactJIDs = [] rosterContactJIDs = []
messages = [] messages = []
@@ -323,6 +356,7 @@ final class AppModel: ObservableObject {
rosterContactJIDs = [] rosterContactJIDs = []
resetMediaSendActivity() resetMediaSendActivity()
resetMediaPreviews() resetMediaPreviews()
prepareStore(for: account.normalizedJID)
self.account = account self.account = account
globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID) globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID) typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID)
@@ -341,6 +375,7 @@ final class AppModel: ObservableObject {
await xmpp.disconnect() await xmpp.disconnect()
self.account = nil self.account = nil
store = nil store = nil
fallbackContext = nil
conversations = [] conversations = []
rosterContactJIDs = [] rosterContactJIDs = []
messages = [] messages = []
@@ -364,6 +399,7 @@ final class AppModel: ObservableObject {
watchSyncTask?.cancel() watchSyncTask?.cancel()
watchSyncTask = nil watchSyncTask = nil
store = nil store = nil
fallbackContext = nil
account = nil account = nil
conversations = [] conversations = []
rosterContactJIDs = [] rosterContactJIDs = []
@@ -884,6 +920,7 @@ final class AppModel: ObservableObject {
let affectedConversations = Set(selected.map(\.conversationID)) let affectedConversations = Set(selected.map(\.conversationID))
for message in selected { for message in selected {
modelContext.delete(message)
locallyDeletedMessageIDs.insert( locallyDeletedMessageIDs.insert(
Self.localDeletionKey( Self.localDeletionKey(
messageID: message.clientID, messageID: message.clientID,
@@ -2116,7 +2153,7 @@ final class AppModel: ObservableObject {
} }
} else { } else {
let conversation = Conversation(jid: normalized, displayName: name) let conversation = Conversation(jid: normalized, displayName: name)
store?.context.insert(conversation) modelContext.insert(conversation)
conversations.append(conversation) conversations.append(conversation)
} }
if conversations.first(where: { $0.jid == normalized })?.isGroup != true { if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
@@ -2169,7 +2206,7 @@ final class AppModel: ObservableObject {
shouldAutojoin: shouldAutojoin, shouldAutojoin: shouldAutojoin,
invitedBy: invitedBy invitedBy: invitedBy
) )
store?.context.insert(conversation) modelContext.insert(conversation)
conversations.append(conversation) conversations.append(conversation)
} }
if !isApplyingArchiveBatch { if !isApplyingArchiveBatch {
@@ -2555,8 +2592,8 @@ final class AppModel: ObservableObject {
// `merged` is the freshly-created incoming message; replace the // `merged` is the freshly-created incoming message; replace the
// previously managed object with it so SwiftData does not keep // previously managed object with it so SwiftData does not keep
// an orphaned duplicate of the same clientID. // an orphaned duplicate of the same clientID.
store?.context.delete(previous) modelContext.delete(previous)
store?.context.insert(merged) modelContext.insert(merged)
} }
if let stanzaID = merged.stanzaID { if let stanzaID = merged.stanzaID {
messageIndexByStanzaKey[ messageIndexByStanzaKey[
@@ -2575,7 +2612,7 @@ final class AppModel: ObservableObject {
updateConversationPreview(for: merged, incrementUnread: false) updateConversationPreview(for: merged, incrementUnread: false)
return false return false
} }
store?.context.insert(message) modelContext.insert(message)
messages.append(message) messages.append(message)
let insertedIndex = messages.index(before: messages.endIndex) let insertedIndex = messages.index(before: messages.endIndex)
messageIndexByStorageKey[ messageIndexByStorageKey[
@@ -2757,8 +2794,9 @@ final class AppModel: ObservableObject {
private func loadArchive(for jid: String) async { private func loadArchive(for jid: String) async {
resetArchiveBatchState() resetArchiveBatchState()
resetMediaPreviews() resetMediaPreviews()
guard let store = try? ArchiveStore(accountJID: jid) else { prepareStore(for: jid)
self.store = nil guard let store else {
fallbackContext = nil
return return
} }
self.store = store self.store = store
@@ -3058,7 +3096,7 @@ final class AppModel: ObservableObject {
} }
private static func localDeletionKey(messageID: String, conversationID: String) -> String { private static func localDeletionKey(messageID: String, conversationID: String) -> String {
conversationID.lowercased() + "\u{1F}" + messageID ArchiveStore.deletionKey(messageID: messageID, conversationID: conversationID)
} }
} }
+52 -2
View File
@@ -43,8 +43,10 @@ final class ArchiveStore {
container = try ModelContainer(for: schema, configurations: [configuration]) container = try ModelContainer(for: schema, configurations: [configuration])
context = ModelContext(container) context = ModelContext(container)
context.autosaveEnabled = false context.autosaveEnabled = false
applyDataProtection()
migrateLegacyJSONIfNeeded() migrateLegacyJSONIfNeeded()
purgeLocallyDeletedMessages()
} }
func load() -> Loaded { func load() -> Loaded {
@@ -86,6 +88,7 @@ final class ArchiveStore {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value) MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
} }
try context.save() try context.save()
applyDataProtection()
} }
func erase() throws { func erase() throws {
@@ -121,11 +124,58 @@ final class ArchiveStore {
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
metadata.mamCheckpoints = imported.mamCheckpoints 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) 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<ChatMessage>())) ?? []
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`:
/// `<conversationID>\u{1F}<messageID>`.
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 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 (partial ^ UInt64(byte)) &* 1_099_511_628_211
} }
@@ -2,6 +2,9 @@ import Foundation
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model` /// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
/// objects once, so existing history survives the migration from the JSON file. /// 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 { enum LegacyArchiveImporter {
struct LegacyConversation: Codable { struct LegacyConversation: Codable {
enum Kind: String, Codable { case direct, group } enum Kind: String, Codable { case direct, group }
@@ -20,6 +23,80 @@ enum LegacyArchiveImporter {
var shouldAutojoin: Bool var shouldAutojoin: Bool
var occupantCount: Int var occupantCount: Int
var invitedBy: String? 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 { struct LegacyMessage: Codable {
@@ -56,9 +133,133 @@ enum LegacyArchiveImporter {
var isGroupMessage: Bool var isGroupMessage: Bool
var callHistory: CallHistoryMetadata? var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction] 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 { struct Snapshot: Codable {
static let currentSchemaVersion = 6
var schemaVersion: Int var schemaVersion: Int
var conversations: [LegacyConversation] var conversations: [LegacyConversation]
var messages: [LegacyMessage] var messages: [LegacyMessage]
@@ -67,6 +268,62 @@ enum LegacyArchiveImporter {
var lastSuccessfulMAMSync: Date? var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String? var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
init(
conversations: [LegacyConversation],
messages: [LegacyMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
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<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.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 { struct Imported {
+70 -1
View File
@@ -2,6 +2,7 @@ import CoreTransferable
import Foundation import Foundation
import ImageIO import ImageIO
import PhotosUI import PhotosUI
import SwiftData
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -67,6 +68,65 @@ struct ChatView: View {
@State private var captureAttemptID = UUID() @State private var captureAttemptID = UUID()
@State private var captureStartTask: Task<Void, Never>? @State private var captureStartTask: Task<Void, Never>?
@State private var captureSuspendsArchiveSync = false @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<ChatMessage> { 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<Task<Void, Never>?>(initialValue: nil)
_pickedMediaItems = State<[PhotosPickerItem]>(initialValue: [])
_attachmentDrafts = State<[AttachmentDraft]>(initialValue: [])
_isPreparingAttachments = State(initialValue: false)
_editingMessageID = State<String?>(initialValue: nil)
_replyingToMessageID = State<String?>(initialValue: nil)
_forwardingSelection = State<MessageForwardSelection?>(initialValue: nil)
_selectedMessageIDs = State<Set<String>>(initialValue: [])
_destructiveAction = State<MessageDestructiveAction?>(initialValue: nil)
_replyThreadSelection = State<ReplyThreadSelection?>(initialValue: nil)
_emojiPickerPresentation = State<EmojiPickerPresentation?>(initialValue: nil)
_hasCompletedInitialScroll = State(initialValue: false)
_isNearTimelineBottom = State(initialValue: true)
_historyLoadAnchorID = State<String?>(initialValue: nil)
_historyTopTriggerArmed = State(initialValue: true)
_activeCaptureMode = State<ComposerCaptureMode?>(initialValue: nil)
_preparingCaptureMode = State<ComposerCaptureMode?>(initialValue: nil)
_captureGestureIsActive = State(initialValue: false)
_captureIsLocked = State(initialValue: false)
_captureDragTranslation = State(initialValue: CGSize.zero)
_captureAttemptID = State(initialValue: UUID())
_captureStartTask = State<Task<Void, Never>?>(initialValue: nil)
_captureSuspendsArchiveSync = State(initialValue: false)
_timelineEntries = State<[ChatTimelineEntry]>(initialValue: [])
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -352,13 +412,16 @@ struct ChatView: View {
model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) } model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) }
} }
private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) {
timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages)
}
private var isSelectingMessages: Bool { private var isSelectingMessages: Bool {
!selectedMessageIDs.isEmpty !selectedMessageIDs.isEmpty
} }
private var messageTimeline: some View { private var messageTimeline: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
let timelineEntries = model.selectedTimelineEntries
GeometryReader { viewport in GeometryReader { viewport in
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
ScrollView { ScrollView {
@@ -468,6 +531,12 @@ struct ChatView: View {
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
#endif #endif
.coordinateSpace(name: Self.timelineCoordinateSpace) .coordinateSpace(name: Self.timelineCoordinateSpace)
.onAppear {
rebuildTimelineEntries()
}
.onChange(of: messages) { _, newMessages in
rebuildTimelineEntries(from: newMessages)
}
#if os(macOS) #if os(macOS)
.onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in
updateTimelineBottomProximity( updateTimelineBottomProximity(
+19 -2
View File
@@ -1,3 +1,4 @@
import SwiftData
import SwiftUI import SwiftUI
@MainActor @MainActor
@@ -10,6 +11,9 @@ struct ForwardMessageView: View {
@State private var destinationJID = "" @State private var destinationJID = ""
@State private var isForwarding = false @State private var isForwarding = false
/// SwiftData-backed destination list, ordered like the main chat list.
@Query private var conversations: [Conversation]
init( init(
model: AppModel, model: AppModel,
messages: [ChatMessage], messages: [ChatMessage],
@@ -18,6 +22,11 @@ struct ForwardMessageView: View {
_model = ObservedObject(wrappedValue: model) _model = ObservedObject(wrappedValue: model)
self.messages = messages.filter(\.canBeForwarded) self.messages = messages.filter(\.canBeForwarded)
self.onComplete = onComplete self.onComplete = onComplete
_conversations = Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
]
)
} }
var body: some View { var body: some View {
@@ -39,9 +48,9 @@ struct ForwardMessageView: View {
} }
} }
if !model.conversations.isEmpty { if !orderedConversations.isEmpty {
Section("Чаты") { Section("Чаты") {
ForEach(model.conversations) { conversation in ForEach(orderedConversations) { conversation in
Button { Button {
forward(to: conversation.jid) forward(to: conversation.jid)
} label: { } label: {
@@ -105,6 +114,14 @@ struct ForwardMessageView: View {
#endif #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) { private func forward(to jid: String) {
guard !isForwarding, !messages.isEmpty else { return } guard !isForwarding, !messages.isEmpty else { return }
isForwarding = true isForwarding = true
+24 -5
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import SwiftData
import SwiftUI import SwiftUI
struct MainChatView: View { struct MainChatView: View {
@@ -21,27 +22,45 @@ struct MainChatView: View {
@State private var showingNewGroup = false @State private var showingNewGroup = false
@State private var showingSettings = 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] { private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return model.conversations } let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch)
return model.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] { private var filteredRosterContacts: [Conversation] {
model.conversations conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
.filter { searchText.isEmpty || matchesSearch($0) } .filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort) .sorted(by: contactSort)
} }
private var filteredGroupContacts: [Conversation] { private var filteredGroupContacts: [Conversation] {
model.conversations conversations
.filter { $0.isGroup } .filter { $0.isGroup }
.filter { searchText.isEmpty || matchesSearch($0) } .filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort) .sorted(by: contactSort)
} }
private var hasStoredContacts: Bool { private var hasStoredContacts: Bool {
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup } !model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup }
} }
var body: some View { var body: some View {
+2
View File
@@ -1,4 +1,5 @@
import QuickLook import QuickLook
import SwiftData
import SwiftUI import SwiftUI
struct RootView: View { struct RootView: View {
@@ -11,6 +12,7 @@ struct RootView: View {
LoginView(model: model) LoginView(model: model)
} else { } else {
MainChatView(model: model) MainChatView(model: model)
.environment(\.modelContext, model.modelContext)
} }
} }
+109 -26
View File
@@ -45,7 +45,6 @@ final class ArchiveStoreTests: XCTestCase {
func testLegacyImportMapsConversationAndMessage() throws { func testLegacyImportMapsConversationAndMessage() throws {
let jid = "test-\(UUID().uuidString)@example.org" let jid = "test-\(UUID().uuidString)@example.org"
let legacy = LegacyArchiveImporter.Snapshot( let legacy = LegacyArchiveImporter.Snapshot(
schemaVersion: 1,
conversations: [ conversations: [
LegacyArchiveImporter.LegacyConversation( LegacyArchiveImporter.LegacyConversation(
id: "bob@example.org", id: "bob@example.org",
@@ -75,32 +74,9 @@ final class ArchiveStoreTests: XCTestCase {
direction: .outgoing, direction: .outgoing,
delivery: .sent, delivery: .sent,
security: .omemo, security: .omemo,
kind: .text, 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: []
) )
], ]
locallyDeletedMessageIDs: [],
rosterContactJIDs: [],
lastSuccessfulMAMSync: nil,
lastSuccessfulMAMCursor: nil,
mamCheckpoints: [:]
) )
let encoder = JSONEncoder() let encoder = JSONEncoder()
@@ -117,4 +93,111 @@ final class ArchiveStoreTests: XCTestCase {
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"]) XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org") 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))
}
} }