migrate persistence from JSON to SwiftData
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

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:
wt committed 2026-08-18 22:58:18 +07:00
1 parent 0317277d46
commit cbb820a86e
30 files changed
+726 -665

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
import SwiftData
import XCTest
@testable import Luma
@MainActor
final class ArchiveStoreTests: XCTestCase {
func testStoreRoundTrip() async throws {
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: "Encrypted hello",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
direction: .outgoing,
delivery: .sent,
security: .omemo
)
store.context.insert(conversation)
store.context.insert(message)
try store.save(
locallyDeletedMessageIDs: ["deleted-message-id"],
rosterContactJIDs: [conversation.jid],
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
lastSuccessfulMAMCursor: "mam-cursor-42",
mamCheckpoints: [:]
)
let loaded = store.load()
XCTAssertEqual(loaded.conversations.map(\.jid), [conversation.jid])
XCTAssertEqual(loaded.messages.map(\.clientID), [message.clientID])
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
XCTAssertEqual(
loaded.lastSuccessfulMAMSync,
Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
}
func testLegacyImportMapsConversationAndMessage() throws {
let jid = "test-\(UUID().uuidString)@example.org"
let legacy = LegacyArchiveImporter.Snapshot(
schemaVersion: 1,
conversations: [
LegacyArchiveImporter.LegacyConversation(
id: "bob@example.org",
jid: "bob@example.org",
displayName: "Bob",
lastMessage: "",
lastActivity: .distantPast,
unreadCount: 0,
isOnline: false,
isPinned: false,
encryptionPreference: .inheritGlobal,
kind: .direct,
groupNickname: nil,
isGroupJoined: false,
shouldAutojoin: false,
occupantCount: 0,
invitedBy: nil
)
],
messages: [
LegacyArchiveImporter.LegacyMessage(
id: "msg-1",
conversationID: "bob@example.org",
senderJID: jid,
body: "Hello",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
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: []
)
],
locallyDeletedMessageIDs: [],
rosterContactJIDs: [],
lastSuccessfulMAMSync: nil,
lastSuccessfulMAMCursor: nil,
mamCheckpoints: [:]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
let data = try encoder.encode(legacy)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-\(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?.messages.map(\.clientID), ["msg-1"])
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org")
}
}
-54
View File
@@ -1,54 +0,0 @@
import XCTest
@testable import Luma
final class ChatArchiveTests: XCTestCase {
func testSnapshotRoundTrip() async throws {
let jid = "test-\(UUID().uuidString)@example.org"
let archive = ChatArchive(accountJID: jid)
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
let message = ChatMessage(
conversationID: conversation.id,
senderJID: jid,
body: "Encrypted hello",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
direction: .outgoing,
delivery: .sent,
security: .omemo
)
try await archive.save(.init(
conversations: [conversation],
messages: [message],
locallyDeletedMessageIDs: ["deleted-message-id"],
rosterContactJIDs: [conversation.jid],
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
lastSuccessfulMAMCursor: "mam-cursor-42"
))
let loaded = await archive.load()
XCTAssertEqual(loaded.conversations, [conversation])
XCTAssertEqual(loaded.messages, [message])
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
XCTAssertEqual(
loaded.lastSuccessfulMAMSync,
Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
XCTAssertEqual(loaded.schemaVersion, ChatArchive.Snapshot.currentSchemaVersion)
try await archive.erase()
}
func testLegacySnapshotDefaultsToSchemaOne() throws {
let data = Data(
#"{"conversations":[],"messages":[],"locallyDeletedMessageIDs":[]}"#.utf8
)
let snapshot = try JSONDecoder().decode(ChatArchive.Snapshot.self, from: data)
XCTAssertEqual(snapshot.schemaVersion, 1)
XCTAssertTrue(snapshot.rosterContactJIDs.isEmpty)
XCTAssertNil(snapshot.lastSuccessfulMAMSync)
XCTAssertNil(snapshot.lastSuccessfulMAMCursor)
}
}
-13
View File
@@ -10,19 +10,6 @@ final class EncryptionPreferenceTests: XCTestCase {
XCTAssertFalse(EncryptionPreference.disabled.resolved(globalEnabled: true))
}
func testConversationWithoutEncryptionFieldStillDecodes() throws {
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
let encoded = try JSONEncoder().encode(conversation)
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
object.removeValue(forKey: "encryptionPreference")
let legacyData = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(Conversation.self, from: legacyData)
XCTAssertEqual(decoded.encryptionPreference, .inheritGlobal)
XCTAssertEqual(decoded.jid, conversation.jid)
}
func testGlobalSettingIsStoredPerAccount() throws {
let suite = "LumaTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+7 -28
View File
@@ -3,7 +3,7 @@ import XCTest
@testable import Luma
final class GroupConversationTests: XCTestCase {
func testGroupConversationRoundTrips() throws {
func testGroupConversationProperties() {
let conversation = Conversation(
jid: "team@conference.example.org",
displayName: "Команда",
@@ -16,33 +16,11 @@ final class GroupConversationTests: XCTestCase {
invitedBy: "bob@example.org"
)
let decoded = try JSONDecoder().decode(
Conversation.self,
from: JSONEncoder().encode(conversation)
)
XCTAssertTrue(decoded.isGroup)
XCTAssertEqual(decoded.groupNickname, "Alice")
XCTAssertEqual(decoded.occupantCount, 4)
XCTAssertEqual(decoded.invitedBy, "bob@example.org")
}
func testLegacyConversationDefaultsToDirect() throws {
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
let encoded = try JSONEncoder().encode(conversation)
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
for key in ["kind", "groupNickname", "isGroupJoined", "shouldAutojoin", "occupantCount", "invitedBy"] {
object.removeValue(forKey: key)
}
let decoded = try JSONDecoder().decode(
Conversation.self,
from: JSONSerialization.data(withJSONObject: object)
)
XCTAssertFalse(decoded.isGroup)
XCTAssertFalse(decoded.isGroupJoined)
XCTAssertEqual(decoded.occupantCount, 0)
XCTAssertTrue(conversation.isGroup)
XCTAssertEqual(conversation.groupNickname, "Alice")
XCTAssertEqual(conversation.occupantCount, 4)
XCTAssertEqual(conversation.invitedBy, "bob@example.org")
XCTAssertEqual(conversation.encryptionPreference, .disabled)
}
func testNewGroupInheritsGlobalEncryptionByDefault() {
@@ -52,5 +30,6 @@ final class GroupConversationTests: XCTestCase {
)
XCTAssertEqual(conversation.encryptionPreference, .inheritGlobal)
XCTAssertTrue(conversation.isGroup)
}
}
-28
View File
@@ -55,32 +55,4 @@ final class MediaMetadataTests: XCTestCase {
XCTAssertFalse(photo.hasSuffix(".aesgcm"))
XCTAssertFalse(voice.hasSuffix(".aesgcm"))
}
func testLegacyMessageWithoutMediaMetadataStillDecodes() throws {
let message = ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
body: "old attachment",
direction: .incoming,
delivery: .delivered,
security: .omemo,
kind: .attachment,
remoteAttachmentURL: "aesgcm://upload.example.org/file.aesgcm#key",
localFilename: "file.pdf"
)
let encoded = try JSONEncoder().encode(message)
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
object.removeValue(forKey: "mimeType")
object.removeValue(forKey: "duration")
object.removeValue(forKey: "byteCount")
let decoded = try JSONDecoder().decode(
ChatMessage.self,
from: JSONSerialization.data(withJSONObject: object)
)
XCTAssertEqual(decoded.localFilename, "file.pdf")
XCTAssertNil(decoded.mimeType)
XCTAssertNil(decoded.duration)
}
}
+24 -47
View File
@@ -4,31 +4,32 @@ import XCTest
final class MessageCorrectionTests: XCTestCase {
func testOnlyDeliveredOrSentOutgoingTextCanBeEdited() {
let editable = ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
body: "До исправления",
direction: .outgoing,
delivery: .sent,
security: .omemo
)
var incoming = editable
incoming.direction = .incoming
var attachment = editable
attachment.kind = .attachment
var failed = editable
failed.delivery = .failed
var retracted = editable
retracted.retractedAt = Date()
func make(
direction: ChatMessage.Direction = .outgoing,
delivery: ChatMessage.Delivery = .sent,
kind: ChatMessage.Kind = .text,
retractedAt: Date? = nil
) -> ChatMessage {
ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
body: "До исправления",
direction: direction,
delivery: delivery,
security: .omemo,
kind: kind,
retractedAt: retractedAt
)
}
XCTAssertTrue(editable.canBeEdited)
XCTAssertFalse(incoming.canBeEdited)
XCTAssertFalse(attachment.canBeEdited)
XCTAssertFalse(failed.canBeEdited)
XCTAssertFalse(retracted.canBeEdited)
XCTAssertTrue(make().canBeEdited)
XCTAssertFalse(make(direction: .incoming).canBeEdited)
XCTAssertFalse(make(kind: .attachment).canBeEdited)
XCTAssertFalse(make(delivery: .failed).canBeEdited)
XCTAssertFalse(make(retractedAt: Date()).canBeEdited)
}
func testEditedTimestampRoundTrips() throws {
func testEditedTimestampIsStored() {
let editedAt = Date(timeIntervalSince1970: 1_700_000_000)
let message = ChatMessage(
conversationID: "bob@example.org",
@@ -40,30 +41,6 @@ final class MessageCorrectionTests: XCTestCase {
editedAt: editedAt
)
let data = try JSONEncoder().encode(message)
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
XCTAssertEqual(decoded.editedAt, editedAt)
}
func testLegacyMessageWithoutEditedTimestampStillDecodes() throws {
let message = ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
body: "Старое сообщение",
direction: .incoming,
delivery: .delivered,
security: .omemo
)
let encoded = try JSONEncoder().encode(message)
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
object.removeValue(forKey: "editedAt")
let decoded = try JSONDecoder().decode(
ChatMessage.self,
from: JSONSerialization.data(withJSONObject: object)
)
XCTAssertNil(decoded.editedAt)
XCTAssertEqual(message.editedAt, editedAt)
}
}
+11 -46
View File
@@ -3,7 +3,7 @@ import XCTest
@testable import Luma
final class MessageInteractionTests: XCTestCase {
func testReplyMetadataRoundTrips() throws {
func testReplyMetadataIsStored() {
let message = ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
@@ -16,12 +16,9 @@ final class MessageInteractionTests: XCTestCase {
replyPreview: "Исходный текст"
)
let data = try JSONEncoder().encode(message)
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
XCTAssertEqual(decoded.replyToID, "original-id")
XCTAssertEqual(decoded.replyToJID, "bob@example.org")
XCTAssertEqual(decoded.replyPreview, "Исходный текст")
XCTAssertEqual(message.replyToID, "original-id")
XCTAssertEqual(message.replyToJID, "bob@example.org")
XCTAssertEqual(message.replyPreview, "Исходный текст")
}
func testRetractedMessageCannotBeEditedRepliedOrForwarded() {
@@ -42,35 +39,6 @@ final class MessageInteractionTests: XCTestCase {
XCTAssertEqual(message.previewText, "🚫 Сообщение удалено")
}
func testLegacyMessageWithoutInteractionFieldsStillDecodes() throws {
let original = ChatMessage(
conversationID: "bob@example.org",
senderJID: "alice@example.org",
body: "Старый архив",
direction: .incoming,
delivery: .delivered,
security: .omemo
)
let encoded = try JSONEncoder().encode(original)
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
for key in [
"replyToID", "replyToJID", "replyPreview", "forwardedFrom", "retractedAt",
"stanzaID", "senderDisplayName", "isGroupMessage", "callHistory"
] {
object.removeValue(forKey: key)
}
let decoded = try JSONDecoder().decode(
ChatMessage.self,
from: JSONSerialization.data(withJSONObject: object)
)
XCTAssertNil(decoded.replyToID)
XCTAssertNil(decoded.retractedAt)
XCTAssertEqual(decoded.body, "Старый архив")
XCTAssertFalse(decoded.isGroupMessage)
}
func testGroupReplyRequiresRoomAssignedStanzaID() {
var message = ChatMessage(
conversationID: "team@conference.example.org",
@@ -92,7 +60,7 @@ final class MessageInteractionTests: XCTestCase {
XCTAssertFalse(message.canBeRetracted)
}
func testCompletedVideoCallHistoryRoundTripsWithDuration() throws {
func testCompletedVideoCallHistoryShowsMetadata() {
let metadata = CallHistoryMetadata(isVideo: true, outcome: .completed)
let message = ChatMessage(
id: "call-alice-123",
@@ -107,14 +75,11 @@ final class MessageInteractionTests: XCTestCase {
callHistory: metadata
)
let data = try JSONEncoder().encode(message)
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
XCTAssertEqual(decoded.callHistory, metadata)
XCTAssertEqual(decoded.callTitle, "Исходящий видеозвонок")
XCTAssertEqual(decoded.callSubtitle, "Длительность 1:05")
XCTAssertEqual(decoded.previewText, "🎥 Исходящий видеозвонок")
XCTAssertFalse(decoded.canBeRepliedTo)
XCTAssertFalse(decoded.canBeForwarded)
XCTAssertEqual(message.callHistory, metadata)
XCTAssertEqual(message.callTitle, "Исходящий видеозвонок")
XCTAssertEqual(message.callSubtitle, "Длительность 1:05")
XCTAssertEqual(message.previewText, "🎥 Исходящий видеозвонок")
XCTAssertFalse(message.canBeRepliedTo)
XCTAssertFalse(message.canBeForwarded)
}
}
-18
View File
@@ -27,22 +27,4 @@ final class MessageReactionTests: XCTestCase {
MessageReactionSummary(emoji: "❤️", count: 1, includesOwnReaction: true)
)
}
func testOlderArchivesDecodeWithoutReactionField() throws {
let message = ChatMessage(
conversationID: "peer@example.org",
senderJID: "peer@example.org",
body: "hello",
direction: .incoming,
delivery: .delivered,
security: .plaintext
)
let encoded = try JSONEncoder().encode(message)
var object = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoded) as? [String: Any]
)
object.removeValue(forKey: "reactions")
let legacyData = try JSONSerialization.data(withJSONObject: object)
XCTAssertEqual(try JSONDecoder().decode(ChatMessage.self, from: legacyData).reactions, [])
}
}