Files
Luma/Sources/Shared/Persistence/LegacyArchiveImporter.swift
T
wt 2884810ec4
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
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.
2026-08-29 00:57:08 +07:00

418 lines
17 KiB
Swift

import Foundation
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
/// objects once, so existing history survives the migration from the JSON file.
/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`:
/// every field that was ever optional or added later falls back to a default,
/// so snapshots written by older schema versions still import.
enum LegacyArchiveImporter {
struct LegacyConversation: Codable {
enum Kind: String, Codable { case direct, group }
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?
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 {
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]
init(
id: String,
conversationID: String,
senderJID: String,
body: String,
timestamp: Date,
direction: Direction,
delivery: Delivery,
security: Security,
kind: Kind,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
encryptionFingerprint: String? = nil,
editedAt: Date? = nil,
replyToID: String? = nil,
replyToJID: String? = nil,
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
originID: String? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.conversationID = conversationID
self.senderJID = senderJID
self.body = body
self.timestamp = timestamp
self.direction = direction
self.delivery = delivery
self.security = security
self.kind = kind
self.remoteAttachmentURL = remoteAttachmentURL
self.localFilename = localFilename
self.mimeType = mimeType
self.duration = duration
self.byteCount = byteCount
self.encryptionFingerprint = encryptionFingerprint
self.editedAt = editedAt
self.replyToID = replyToID
self.replyToJID = replyToJID
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
self.originID = originID
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
self.callHistory = callHistory
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case originID
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = try values.decode(String.self, forKey: .senderJID)
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(
String.self,
forKey: .encryptionFingerprint
)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
originID = try values.decodeIfPresent(String.self, forKey: .originID)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
}
struct Snapshot: Codable {
static let currentSchemaVersion = 6
var schemaVersion: Int
var conversations: [LegacyConversation]
var messages: [LegacyMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
init(
conversations: [LegacyConversation],
messages: [LegacyMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
) {
self.schemaVersion = Self.currentSchemaVersion
self.conversations = conversations
self.messages = messages
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
self.mamCheckpoints = mamCheckpoints
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case conversations
case messages
case locallyDeletedMessageIDs
case rosterContactJIDs
case lastSuccessfulMAMSync
case lastSuccessfulMAMCursor
case mamCheckpoints
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
conversations = try values.decode([LegacyConversation].self, forKey: .conversations)
messages = try values.decode([LegacyMessage].self, forKey: .messages)
locallyDeletedMessageIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .rosterContactJIDs
) ?? []
lastSuccessfulMAMSync = try values.decodeIfPresent(
Date.self,
forKey: .lastSuccessfulMAMSync
)
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
)
mamCheckpoints = try values.decodeIfPresent(
[MAMArchiveKey: MAMArchiveCheckpoint].self,
forKey: .mamCheckpoints
) ?? [:]
}
}
struct Imported {
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)
}
)
}
}