finish SwiftData migration with @Query views
- 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:
1 parent
277d8654c3
commit
2884810ec4
16 files changed
+653
-65
No files matched your search
@@ -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,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
@@ -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
|
||||||
|
|||||||
BIN
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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user