migrate persistence from JSON to SwiftData
Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions.
This commit is contained in:
1 parent
0317277d46
commit
cbb820a86e
30 files changed
+726
-665
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Codable representation of a single durable MAM checkpoint, keyed by the
|
||||
/// archive it belongs to (account or a specific MUC).
|
||||
struct MAMCheckpointEntry: Codable, Equatable, Sendable {
|
||||
let key: MAMArchiveKey
|
||||
let checkpoint: MAMArchiveCheckpoint
|
||||
}
|
||||
|
||||
/// Singleton per-account metadata that used to live in the JSON snapshot:
|
||||
/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
|
||||
@Model
|
||||
final class ArchiveMetadata {
|
||||
@Attribute(.unique) var accountJID: String
|
||||
var locallyDeletedMessageIDs: [String]
|
||||
var rosterContactJIDs: [String]
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMCheckpointEntry]
|
||||
|
||||
init(
|
||||
accountJID: String,
|
||||
locallyDeletedMessageIDs: [String] = [],
|
||||
rosterContactJIDs: [String] = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMCheckpointEntry] = []
|
||||
) {
|
||||
self.accountJID = accountJID
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
|
||||
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
|
||||
/// once, and exposes load/save/erase.
|
||||
@MainActor
|
||||
final class ArchiveStore {
|
||||
struct Loaded {
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
let container: ModelContainer
|
||||
let context: ModelContext
|
||||
private let accountJID: String
|
||||
private let storeURL: URL
|
||||
private let legacyJSONURL: URL
|
||||
private(set) var metadata: ArchiveMetadata?
|
||||
|
||||
init(accountJID: String) throws {
|
||||
self.accountJID = accountJID
|
||||
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)
|
||||
let hash = Self.stableHash(accountJID)
|
||||
storeURL = directory.appendingPathComponent("\(hash).store")
|
||||
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
|
||||
|
||||
let schema = Schema([
|
||||
Conversation.self,
|
||||
ChatMessage.self,
|
||||
ArchiveMetadata.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, url: storeURL)
|
||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
context = ModelContext(container)
|
||||
context.autosaveEnabled = false
|
||||
|
||||
migrateLegacyJSONIfNeeded()
|
||||
}
|
||||
|
||||
func load() -> Loaded {
|
||||
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
|
||||
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
|
||||
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
|
||||
|
||||
let metadata = fetchMetadata()
|
||||
let mamCheckpoints = Dictionary(
|
||||
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
return Loaded(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Set(metadata.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: mamCheckpoints
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists the passed metadata plus all pending model mutations.
|
||||
func save(
|
||||
locallyDeletedMessageIDs: Set<String>,
|
||||
rosterContactJIDs: Set<String>,
|
||||
lastSuccessfulMAMSync: Date?,
|
||||
lastSuccessfulMAMCursor: String?,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
) throws {
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
|
||||
metadata.rosterContactJIDs = Array(rosterContactJIDs)
|
||||
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
|
||||
metadata.mamCheckpoints = mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
try context.delete(model: Conversation.self)
|
||||
try context.delete(model: ChatMessage.self)
|
||||
try context.delete(model: ArchiveMetadata.self)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
private func fetchMetadata() -> ArchiveMetadata {
|
||||
if let metadata { return metadata }
|
||||
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
|
||||
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
|
||||
context.insert(metadata)
|
||||
self.metadata = metadata
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func migrateLegacyJSONIfNeeded() {
|
||||
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
|
||||
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
|
||||
return
|
||||
}
|
||||
for conversation in imported.conversations {
|
||||
context.insert(conversation)
|
||||
}
|
||||
for message in imported.messages {
|
||||
context.insert(message)
|
||||
}
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
|
||||
metadata.rosterContactJIDs = imported.rosterContactJIDs
|
||||
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
|
||||
metadata.mamCheckpoints = imported.mamCheckpoints
|
||||
try? context.save()
|
||||
try? FileManager.default.removeItem(at: legacyJSONURL)
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
actor ChatArchive {
|
||||
struct Snapshot: Codable, Sendable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
var schemaVersion: Int
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
|
||||
init(
|
||||
conversations: [Conversation],
|
||||
messages: [ChatMessage],
|
||||
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 = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
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([Conversation].self, forKey: .conversations)
|
||||
messages = try values.decode([ChatMessage].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
|
||||
) ?? [:]
|
||||
}
|
||||
|
||||
static let empty = Snapshot(conversations: [], messages: [])
|
||||
}
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(accountJID: String) {
|
||||
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
|
||||
}
|
||||
|
||||
func load() -> Snapshot {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
|
||||
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
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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.
|
||||
enum LegacyArchiveImporter {
|
||||
struct LegacyConversation: Codable {
|
||||
enum Kind: String, Codable { case direct, group }
|
||||
var id: String
|
||||
var jid: String
|
||||
var displayName: String
|
||||
var lastMessage: String
|
||||
var lastActivity: Date
|
||||
var unreadCount: Int
|
||||
var isOnline: Bool
|
||||
var isPinned: Bool
|
||||
var encryptionPreference: EncryptionPreference
|
||||
var kind: Kind
|
||||
var groupNickname: String?
|
||||
var isGroupJoined: Bool
|
||||
var shouldAutojoin: Bool
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
}
|
||||
|
||||
struct LegacyMessage: Codable {
|
||||
enum Direction: String, Codable { case incoming, outgoing }
|
||||
enum Delivery: String, Codable { case sending, sent, delivered, failed }
|
||||
enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
|
||||
enum Kind: String, Codable {
|
||||
case text, attachment, photo, video, audio, voice, videoNote, location, system
|
||||
}
|
||||
var id: String
|
||||
var conversationID: String
|
||||
var senderJID: String
|
||||
var body: String
|
||||
var timestamp: Date
|
||||
var direction: Direction
|
||||
var delivery: Delivery
|
||||
var security: Security
|
||||
var kind: Kind
|
||||
var remoteAttachmentURL: String?
|
||||
var localFilename: String?
|
||||
var mimeType: String?
|
||||
var duration: TimeInterval?
|
||||
var byteCount: Int?
|
||||
var encryptionFingerprint: String?
|
||||
var editedAt: Date?
|
||||
var replyToID: String?
|
||||
var replyToJID: String?
|
||||
var replyPreview: String?
|
||||
var forwardedFrom: String?
|
||||
var retractedAt: Date?
|
||||
var originID: String?
|
||||
var stanzaID: String?
|
||||
var senderDisplayName: String?
|
||||
var isGroupMessage: Bool
|
||||
var callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
}
|
||||
|
||||
struct Snapshot: Codable {
|
||||
var schemaVersion: Int
|
||||
var conversations: [LegacyConversation]
|
||||
var messages: [LegacyMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
struct Imported {
|
||||
let conversations: [Conversation]
|
||||
let messages: [ChatMessage]
|
||||
let locallyDeletedMessageIDs: [String]
|
||||
let rosterContactJIDs: [String]
|
||||
let lastSuccessfulMAMSync: Date?
|
||||
let lastSuccessfulMAMCursor: String?
|
||||
let mamCheckpoints: [MAMCheckpointEntry]
|
||||
}
|
||||
|
||||
static func importIfNeeded(from url: URL) throws -> Imported? {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
let snapshot = try decoder.decode(Snapshot.self, from: data)
|
||||
|
||||
let conversations = snapshot.conversations.map { legacy -> Conversation in
|
||||
let conversation = Conversation(
|
||||
jid: legacy.jid,
|
||||
displayName: legacy.displayName,
|
||||
lastMessage: legacy.lastMessage,
|
||||
lastActivity: legacy.lastActivity,
|
||||
unreadCount: legacy.unreadCount,
|
||||
isOnline: legacy.isOnline,
|
||||
isPinned: legacy.isPinned,
|
||||
encryptionPreference: legacy.encryptionPreference,
|
||||
kind: legacy.kind == .group ? .group : .direct,
|
||||
groupNickname: legacy.groupNickname,
|
||||
isGroupJoined: legacy.isGroupJoined,
|
||||
shouldAutojoin: legacy.shouldAutojoin,
|
||||
occupantCount: legacy.occupantCount,
|
||||
invitedBy: legacy.invitedBy
|
||||
)
|
||||
return conversation
|
||||
}
|
||||
|
||||
let conversationByJID = Dictionary(
|
||||
conversations.map { ($0.jid, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let messages = snapshot.messages.map { legacy -> ChatMessage in
|
||||
let message = ChatMessage(
|
||||
id: legacy.id,
|
||||
conversationID: legacy.conversationID,
|
||||
senderJID: legacy.senderJID,
|
||||
body: legacy.body,
|
||||
timestamp: legacy.timestamp,
|
||||
direction: legacy.direction == .outgoing ? .outgoing : .incoming,
|
||||
delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
|
||||
security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
|
||||
kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
|
||||
remoteAttachmentURL: legacy.remoteAttachmentURL,
|
||||
localFilename: legacy.localFilename,
|
||||
mimeType: legacy.mimeType,
|
||||
duration: legacy.duration,
|
||||
byteCount: legacy.byteCount,
|
||||
encryptionFingerprint: legacy.encryptionFingerprint,
|
||||
editedAt: legacy.editedAt,
|
||||
replyToID: legacy.replyToID,
|
||||
replyToJID: legacy.replyToJID,
|
||||
replyPreview: legacy.replyPreview,
|
||||
forwardedFrom: legacy.forwardedFrom,
|
||||
retractedAt: legacy.retractedAt,
|
||||
originID: legacy.originID,
|
||||
stanzaID: legacy.stanzaID,
|
||||
senderDisplayName: legacy.senderDisplayName,
|
||||
isGroupMessage: legacy.isGroupMessage,
|
||||
callHistory: legacy.callHistory,
|
||||
reactions: legacy.reactions
|
||||
)
|
||||
message.conversation = conversationByJID[legacy.conversationID.lowercased()]
|
||||
return message
|
||||
}
|
||||
|
||||
return Imported(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Array(snapshot.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: snapshot.mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user