store chat history in SwiftData
Replace the per-account JSON snapshot with SwiftData @Model records (ConversationRecord, MessageRecord, ArchiveMetadataRecord). The in-memory value types stay unchanged; ChatArchive now persists through a shared ModelContainer filtered by accountJID. Legacy JSON is migrated once on first load and then removed.
This commit is contained in:
+13
-2
@@ -9,7 +9,7 @@
|
||||
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
|
||||
AES-GCM выполняется CryptoKit.
|
||||
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
|
||||
сообщения и сохраняет snapshot локально.
|
||||
сообщения и сохраняет snapshot локально через SwiftData.
|
||||
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
|
||||
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
|
||||
текстовые ответы возвращаются немедленно или через гарантированную очередь,
|
||||
@@ -18,6 +18,17 @@
|
||||
XMPP, отправляет запись через тот же XEP-0363/OMEMO-пайплайн и возвращает
|
||||
часам отдельный результат фактической отправки.
|
||||
|
||||
## Локальное хранение
|
||||
|
||||
История чатов сохраняется в SwiftData: `ChatArchive` работает поверх `@Model`-
|
||||
записей `ConversationRecord`, `MessageRecord` и `ArchiveMetadataRecord`
|
||||
(`Sources/Shared/Persistence/`). Доменные типы `Conversation`/`ChatMessage`
|
||||
остаются value-типами в памяти, а SwiftData — только слой долговременного
|
||||
хранения. Контейнер один на приложение и фильтрует записи по `accountJID`.
|
||||
Вложенные Codable-значения (реакции, call-метаданные, MAM-чекпоинты) хранятся
|
||||
как `Data`-атрибуты. При первом запуске после обновления старый JSON-снапшот
|
||||
(`Luma/Accounts/<hash>.json`) однократно переносится в SwiftData и удаляется.
|
||||
|
||||
## Контакты
|
||||
|
||||
`RosterModule` Martin передаёт initial roster и последующие roster-push в
|
||||
@@ -205,6 +216,6 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
|
||||
- APNs provider + XEP-0357 registration;
|
||||
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
|
||||
- MIX и реакции;
|
||||
- SQLite/SQLCipher вместо JSON snapshot;
|
||||
- шифрование SQLite-хранилища (SQLCipher-уровня) поверх SwiftData;
|
||||
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
||||
- share extension и notification service extension.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
actor ChatArchive {
|
||||
/// Durable per-account chat history.
|
||||
///
|
||||
/// The in-memory domain model stays the value types `Conversation` and
|
||||
/// `ChatMessage`; this type is only the persistence boundary. Storage is
|
||||
/// SwiftData (`@Model` records in `ArchiveRecords.swift`) instead of the
|
||||
/// former per-account JSON snapshot file. A one-shot migration reads the legacy
|
||||
/// JSON file on first launch and seeds the SwiftData store.
|
||||
final class ChatArchive {
|
||||
struct Snapshot: Codable, Sendable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
@@ -73,47 +81,148 @@ actor ChatArchive {
|
||||
static let empty = Snapshot(conversations: [], messages: [])
|
||||
}
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let accountJID: String
|
||||
private let legacyFileURL: URL
|
||||
|
||||
init(accountJID: String) {
|
||||
self.accountJID = accountJID.lowercased()
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
legacyFileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
}
|
||||
|
||||
func load() -> Snapshot {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
|
||||
func load() async -> Snapshot {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
return await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
|
||||
guard
|
||||
let conversations = try? context.fetch(FetchDescriptor<ConversationRecord>()),
|
||||
let messages = try? context.fetch(FetchDescriptor<MessageRecord>()),
|
||||
let metadata = try? context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
else {
|
||||
return Snapshot.empty
|
||||
}
|
||||
|
||||
let accountConversations = conversations.filter { $0.accountJID == jid }
|
||||
let accountMessages = messages.filter { $0.accountJID == jid }
|
||||
let accountMetadata = metadata.filter { $0.accountJID == jid }
|
||||
|
||||
if !accountConversations.isEmpty || !accountMessages.isEmpty || !accountMetadata.isEmpty {
|
||||
return Self.snapshot(
|
||||
conversations: accountConversations,
|
||||
messages: accountMessages,
|
||||
metadata: accountMetadata.first
|
||||
)
|
||||
}
|
||||
|
||||
// The SwiftData store is empty for this account: migrate the legacy
|
||||
// JSON snapshot once, if it still exists.
|
||||
if let legacy = Self.decodeLegacySnapshot(from: legacyURL) {
|
||||
try? Self.replaceAll(with: legacy, accountJID: jid, context: context)
|
||||
try? context.save()
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
return legacy
|
||||
}
|
||||
|
||||
return .empty
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func save(_ snapshot: Snapshot) throws {
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: fileURL.path
|
||||
func save(_ snapshot: Snapshot) async throws {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
try await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
try Self.replaceAll(with: snapshot, accountJID: jid, context: context)
|
||||
try context.save()
|
||||
// The legacy file is stale once SwiftData owns the history.
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func erase() async throws {
|
||||
let jid = accountJID
|
||||
let legacyURL = legacyFileURL
|
||||
try await MainActor.run {
|
||||
let context = LumaPersistence.archiveContainer.mainContext
|
||||
|
||||
for record in try context.fetch(FetchDescriptor<ConversationRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<MessageRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
where record.accountJID == jid {
|
||||
context.delete(record)
|
||||
}
|
||||
try context.save()
|
||||
try? FileManager.default.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func snapshot(
|
||||
conversations: [ConversationRecord],
|
||||
messages: [MessageRecord],
|
||||
metadata: ArchiveMetadataRecord?
|
||||
) -> Snapshot {
|
||||
Snapshot(
|
||||
conversations: conversations.map { $0.value },
|
||||
messages: messages.map { $0.value },
|
||||
locallyDeletedMessageIDs: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([String].self, from: $0.locallyDeletedMessageIDsData).map { Set($0) }
|
||||
} ?? [],
|
||||
rosterContactJIDs: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([String].self, from: $0.rosterContactJIDsData).map { Set($0) }
|
||||
} ?? [],
|
||||
lastSuccessfulMAMSync: metadata?.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: metadata?.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: metadata.flatMap {
|
||||
ArchiveBlobCodec.decode([MAMArchiveKey: MAMArchiveCheckpoint].self, from: $0.mamCheckpointsData)
|
||||
} ?? [:]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
private static func replaceAll(
|
||||
with snapshot: Snapshot,
|
||||
accountJID: String,
|
||||
context: ModelContext
|
||||
) throws {
|
||||
for record in try context.fetch(FetchDescriptor<ConversationRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<MessageRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
for record in try context.fetch(FetchDescriptor<ArchiveMetadataRecord>())
|
||||
where record.accountJID == accountJID {
|
||||
context.delete(record)
|
||||
}
|
||||
|
||||
for conversation in snapshot.conversations {
|
||||
context.insert(ConversationRecord(accountJID: accountJID, conversation: conversation))
|
||||
}
|
||||
for message in snapshot.messages {
|
||||
context.insert(MessageRecord(accountJID: accountJID, message: message))
|
||||
}
|
||||
context.insert(ArchiveMetadataRecord(accountJID: accountJID, snapshot: snapshot))
|
||||
}
|
||||
|
||||
private static func decodeLegacySnapshot(from url: URL) -> Snapshot? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return try? decoder.decode(Snapshot.self, from: data)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
|
||||
Reference in New Issue
Block a user