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/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.
+9 -5
View File
@@ -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.
+13 -11
View File
@@ -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
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"
},
{
"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",
+39 -1
View File
@@ -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
}
+46 -8
View File
@@ -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<Void, Never>?
private var persistTask: Task<Void, Never>?
private var watchSyncTask: Task<Void, Never>?
@@ -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)
}
}
+52 -2
View File
@@ -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<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
(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`
/// 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<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 {
+70 -1
View File
@@ -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<Void, Never>?
@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 {
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(
+19 -2
View File
@@ -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
+24 -5
View File
@@ -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 {
+2
View File
@@ -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)
}
}
+109 -26
View File
@@ -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))
}
}