mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1396 lines
70 KiB
Swift
1396 lines
70 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import AVKit
|
|
import GRDB
|
|
import Curve25519Kit
|
|
import SessionUtilitiesKit
|
|
import SessionSnodeKit
|
|
|
|
// Note: Looks like the oldest iOS device we support (min iOS 13.0) has 2Gb of RAM, processing
|
|
// ~250k messages and ~1000 threads seems to take up
|
|
enum _003_YDBToGRDBMigration: Migration {
|
|
static let identifier: String = "YDBToGRDBMigration"
|
|
|
|
static func migrate(_ db: Database) throws {
|
|
// MARK: - Process Contacts, Threads & Interactions
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - SessionMessagingKit migration - Start")
|
|
var shouldFailMigration: Bool = false
|
|
var contacts: Set<Legacy.Contact> = []
|
|
var validProfileIds: Set<String> = []
|
|
var contactThreadIds: Set<String> = []
|
|
|
|
var legacyThreadIdToIdMap: [String: String] = [:]
|
|
var threads: Set<TSThread> = []
|
|
var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:]
|
|
|
|
var closedGroupKeys: [String: [TimeInterval: SUKLegacy.KeyPair]] = [:]
|
|
var closedGroupName: [String: String] = [:]
|
|
var closedGroupFormation: [String: UInt64] = [:]
|
|
var closedGroupModel: [String: TSGroupModel] = [:]
|
|
var closedGroupZombieMemberIds: [String: Set<String>] = [:]
|
|
|
|
var openGroupInfo: [String: OpenGroupV2] = [:]
|
|
var openGroupUserCount: [String: Int] = [:]
|
|
var openGroupImage: [String: Data] = [:]
|
|
var openGroupLastMessageServerId: [String: Int64] = [:] // Optional
|
|
var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional
|
|
// var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed????
|
|
|
|
var interactions: [String: [TSInteraction]] = [:]
|
|
var attachments: [String: Legacy.Attachment] = [:]
|
|
var processedAttachmentIds: Set<String> = []
|
|
var outgoingReadReceiptsTimestampsMs: [String: Set<Int64>] = [:]
|
|
var receivedMessageTimestamps: Set<UInt64> = []
|
|
|
|
// Map the Legacy types for the NSKeyedUnarchiver
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.Contact.self,
|
|
forClassName: "SNContact"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.Attachment.self,
|
|
forClassName: "TSAttachment"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.AttachmentStream.self,
|
|
forClassName: "TSAttachmentStream"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.AttachmentPointer.self,
|
|
forClassName: "TSAttachmentPointer"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.DisappearingConfigurationUpdateInfoMessage.self,
|
|
forClassName: "OWSDisappearingConfigurationUpdateInfoMessage"
|
|
)
|
|
|
|
Storage.read { transaction in
|
|
// Process the Contacts
|
|
transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in
|
|
guard let contact = object as? Legacy.Contact else { return }
|
|
contacts.insert(contact)
|
|
validProfileIds.insert(contact.sessionID)
|
|
}
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start")
|
|
|
|
// Process the threads
|
|
transaction.enumerateKeysAndObjects(inCollection: Legacy.threadCollection) { key, object, _ in
|
|
guard let thread: TSThread = object as? TSThread else { return }
|
|
guard let threadId: String = thread.uniqueId else { return }
|
|
|
|
threads.insert(thread)
|
|
|
|
// Want to exclude threads which aren't visible (ie. threads which we started
|
|
// but the user never ended up sending a message)
|
|
if key.starts(with: Legacy.contactThreadPrefix) && thread.shouldBeVisible {
|
|
contactThreadIds.insert(key)
|
|
}
|
|
|
|
// Get the disappearing messages config
|
|
disappearingMessagesConfiguration[threadId] = transaction
|
|
.object(forKey: threadId, inCollection: Legacy.disappearingMessagesCollection)
|
|
.asType(Legacy.DisappearingMessagesConfiguration.self)
|
|
|
|
// Process group-specific info
|
|
guard let groupThread: TSGroupThread = thread as? TSGroupThread else {
|
|
legacyThreadIdToIdMap[threadId] = threadId.substring(from: Legacy.contactThreadPrefix.count)
|
|
return
|
|
}
|
|
|
|
if groupThread.isClosedGroup {
|
|
// The old threadId for closed groups was in the below format, we don't
|
|
// really need the unnecessary complexity so process the key and extract
|
|
// the publicKey from it
|
|
// `g{base64String(Data(__textsecure_group__!{publicKey}))}
|
|
let base64GroupId: String = String(threadId.suffix(from: threadId.index(after: threadId.startIndex)))
|
|
guard
|
|
let groupIdData: Data = Data(base64Encoded: base64GroupId),
|
|
let groupId: String = String(data: groupIdData, encoding: .utf8),
|
|
let publicKey: String = groupId.split(separator: "!").last.map({ String($0) })
|
|
else {
|
|
SNLog("[Migration Error] Unable to decode Closed Group")
|
|
shouldFailMigration = true
|
|
return
|
|
}
|
|
|
|
legacyThreadIdToIdMap[threadId] = publicKey
|
|
closedGroupName[threadId] = groupThread.name(with: transaction)
|
|
closedGroupModel[threadId] = groupThread.groupModel
|
|
closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0)
|
|
closedGroupZombieMemberIds[threadId] = transaction.object(
|
|
forKey: publicKey,
|
|
inCollection: Legacy.closedGroupZombieMembersCollection
|
|
) as? Set<String>
|
|
|
|
// Note: If the user is no longer in a closed group then the group will still exist but the user
|
|
// won't have the closed group public key anymore
|
|
let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)"
|
|
|
|
transaction.enumerateKeysAndObjects(inCollection: keyCollection) { key, object, _ in
|
|
guard
|
|
let timestamp: TimeInterval = TimeInterval(key),
|
|
let keyPair: SUKLegacy.KeyPair = object as? SUKLegacy.KeyPair
|
|
else { return }
|
|
|
|
closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:])
|
|
.setting(timestamp, keyPair)
|
|
}
|
|
}
|
|
else if groupThread.isOpenGroup {
|
|
guard let openGroup: OpenGroupV2 = transaction.object(forKey: threadId, inCollection: Legacy.openGroupCollection) as? OpenGroupV2 else {
|
|
SNLog("[Migration Error] Unable to find open group info")
|
|
shouldFailMigration = true
|
|
return
|
|
}
|
|
|
|
legacyThreadIdToIdMap[threadId] = OpenGroup.idFor(
|
|
room: openGroup.room,
|
|
server: openGroup.server
|
|
)
|
|
openGroupInfo[threadId] = openGroup
|
|
openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0)
|
|
openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data
|
|
openGroupLastMessageServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastMessageServerIDCollection) as? Int64
|
|
openGroupLastDeletionServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastDeletionServerIDCollection) as? Int64
|
|
}
|
|
}
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - End")
|
|
|
|
// Process interactions
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start")
|
|
transaction.enumerateKeysAndObjects(inCollection: Legacy.interactionCollection) { _, object, _ in
|
|
guard let interaction: TSInteraction = object as? TSInteraction else {
|
|
SNLog("[Migration Error] Unable to process interaction")
|
|
shouldFailMigration = true
|
|
return
|
|
}
|
|
|
|
interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? [])
|
|
.appending(interaction)
|
|
}
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - End")
|
|
|
|
// Process attachments
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start")
|
|
transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in
|
|
guard let attachment: Legacy.Attachment = object as? Legacy.Attachment else {
|
|
SNLog("[Migration Error] Unable to process attachment")
|
|
shouldFailMigration = true
|
|
return
|
|
}
|
|
|
|
attachments[key] = attachment
|
|
}
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End")
|
|
|
|
// Process read receipts
|
|
transaction.enumerateKeysAndObjects(inCollection: Legacy.outgoingReadReceiptManagerCollection) { key, object, _ in
|
|
guard let timestampsMs: Set<Int64> = object as? Set<Int64> else { return }
|
|
|
|
outgoingReadReceiptsTimestampsMs[key] = (outgoingReadReceiptsTimestampsMs[key] ?? Set())
|
|
.union(timestampsMs)
|
|
}
|
|
|
|
receivedMessageTimestamps = receivedMessageTimestamps.inserting(
|
|
contentsOf: transaction
|
|
.object(
|
|
forKey: Legacy.receivedMessageTimestampsKey,
|
|
inCollection: Legacy.receivedMessageTimestampsCollection
|
|
)
|
|
.asType([UInt64].self)
|
|
.defaulting(to: [])
|
|
.asSet()
|
|
)
|
|
}
|
|
|
|
// We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here
|
|
guard !shouldFailMigration else { throw GRDBStorageError.migrationFailed }
|
|
|
|
// Insert the data into GRDB
|
|
|
|
// MARK: - Insert Contacts
|
|
|
|
try autoreleasepool {
|
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
|
|
try contacts.forEach { contact in
|
|
let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey)
|
|
let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID)
|
|
|
|
// TODO: Contact 'hasOne' profile???
|
|
// Create the "Profile" for the legacy contact
|
|
try Profile(
|
|
id: contact.sessionID,
|
|
name: (contact.name ?? contact.sessionID),
|
|
nickname: contact.nickname,
|
|
profilePictureUrl: contact.profilePictureURL,
|
|
profilePictureFileName: contact.profilePictureFileName,
|
|
profileEncryptionKey: contact.profileEncryptionKey
|
|
).insert(db)
|
|
|
|
// Determine if this contact is a "real" contact (don't want to create contacts for
|
|
// every user in the new structure but still want profiles for every user)
|
|
if
|
|
isCurrentUser ||
|
|
contactThreadIds.contains(contactThreadId) ||
|
|
contact.isApproved ||
|
|
contact.didApproveMe ||
|
|
contact.isBlocked ||
|
|
contact.hasBeenBlocked {
|
|
// Create the contact
|
|
// TODO: Closed group admins???
|
|
try Contact(
|
|
id: contact.sessionID,
|
|
isTrusted: (isCurrentUser || contact.isTrusted),
|
|
isApproved: (isCurrentUser || contact.isApproved),
|
|
isBlocked: (!isCurrentUser && contact.isBlocked),
|
|
didApproveMe: (isCurrentUser || contact.didApproveMe),
|
|
hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked))
|
|
).insert(db)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Insert Threads
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start")
|
|
var legacyInteractionToIdMap: [String: Int64] = [:]
|
|
var legacyInteractionIdentifierToIdMap: [String: Int64] = [:]
|
|
var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:]
|
|
|
|
func identifier(
|
|
for threadId: String,
|
|
sentTimestamp: UInt64,
|
|
recipients: [String],
|
|
destination: Message.Destination?,
|
|
variant: Interaction.Variant?,
|
|
useFallback: Bool
|
|
) -> String {
|
|
let recipientString: String = {
|
|
if let destination: Message.Destination = destination {
|
|
switch destination {
|
|
case .contact(let publicKey): return publicKey
|
|
default: break
|
|
}
|
|
}
|
|
|
|
return (recipients.first ?? "0")
|
|
}()
|
|
|
|
return [
|
|
(useFallback ?
|
|
// Fallback to seconds-based accuracy (instead of milliseconds)
|
|
String("\(sentTimestamp)".prefix("\(Int(Date().timeIntervalSince1970))".count)) :
|
|
"\(sentTimestamp)"
|
|
),
|
|
(useFallback ? variant.map { "\($0)" } : nil),
|
|
recipientString,
|
|
threadId
|
|
]
|
|
.compactMap { $0 }
|
|
.joined(separator: "-")
|
|
}
|
|
|
|
try threads.forEach { thread in
|
|
guard
|
|
let legacyThreadId: String = thread.uniqueId,
|
|
let threadId: String = legacyThreadIdToIdMap[legacyThreadId]
|
|
else {
|
|
SNLog("[Migration Error] Unable to migrate thread with no id mapping")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
let threadVariant: SessionThread.Variant
|
|
let onlyNotifyForMentions: Bool
|
|
|
|
switch thread {
|
|
case let groupThread as TSGroupThread:
|
|
threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup)
|
|
onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions
|
|
|
|
default:
|
|
threadVariant = .contact
|
|
onlyNotifyForMentions = false
|
|
}
|
|
|
|
try autoreleasepool {
|
|
try SessionThread(
|
|
id: threadId,
|
|
variant: threadVariant,
|
|
creationDateTimestamp: thread.creationDate.timeIntervalSince1970,
|
|
shouldBeVisible: thread.shouldBeVisible,
|
|
isPinned: thread.isPinned,
|
|
messageDraft: ((thread.messageDraft ?? "").isEmpty ?
|
|
nil :
|
|
thread.messageDraft
|
|
),
|
|
mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970,
|
|
onlyNotifyForMentions: onlyNotifyForMentions
|
|
).insert(db)
|
|
|
|
// Disappearing Messages Configuration
|
|
if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] {
|
|
try DisappearingMessagesConfiguration(
|
|
threadId: threadId,
|
|
isEnabled: config.isEnabled,
|
|
durationSeconds: TimeInterval(config.durationSeconds)
|
|
).insert(db)
|
|
}
|
|
else {
|
|
try DisappearingMessagesConfiguration
|
|
.defaultWith(threadId)
|
|
.insert(db)
|
|
}
|
|
|
|
// Closed Groups
|
|
if (thread as? TSGroupThread)?.isClosedGroup == true {
|
|
guard
|
|
let name: String = closedGroupName[legacyThreadId],
|
|
let groupModel: TSGroupModel = closedGroupModel[legacyThreadId],
|
|
let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId]
|
|
else {
|
|
SNLog("[Migration Error] Closed group missing required data")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
try ClosedGroup(
|
|
threadId: threadId,
|
|
name: name,
|
|
formationTimestamp: TimeInterval(formationTimestamp)
|
|
).insert(db)
|
|
|
|
// Note: If a user has left a closed group then they won't actually have any keys
|
|
// but they should still be able to browse the old messages so we do want to allow
|
|
// this case and migrate the rest of the info
|
|
try closedGroupKeys[legacyThreadId]?.forEach { timestamp, legacyKeys in
|
|
try ClosedGroupKeyPair(
|
|
threadId: threadId,
|
|
publicKey: legacyKeys.publicKey,
|
|
secretKey: legacyKeys.privateKey,
|
|
receivedTimestamp: timestamp
|
|
).insert(db)
|
|
}
|
|
|
|
try groupModel.groupMemberIds.forEach { memberId in
|
|
try GroupMember(
|
|
groupId: threadId,
|
|
profileId: memberId,
|
|
role: .standard
|
|
).insert(db)
|
|
}
|
|
|
|
try groupModel.groupAdminIds.forEach { adminId in
|
|
try GroupMember(
|
|
groupId: threadId,
|
|
profileId: adminId,
|
|
role: .admin
|
|
).insert(db)
|
|
}
|
|
|
|
try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in
|
|
try GroupMember(
|
|
groupId: threadId,
|
|
profileId: zombieId,
|
|
role: .zombie
|
|
).insert(db)
|
|
}
|
|
}
|
|
|
|
// Open Groups
|
|
if (thread as? TSGroupThread)?.isOpenGroup == true {
|
|
guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else {
|
|
SNLog("[Migration Error] Open group missing required data")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
try OpenGroup(
|
|
server: openGroup.server,
|
|
room: openGroup.room,
|
|
publicKey: openGroup.publicKey,
|
|
name: openGroup.name,
|
|
groupDescription: nil, // TODO: Add with SOGS V4.
|
|
imageId: nil, // TODO: Add with SOGS V4.
|
|
imageData: openGroupImage[legacyThreadId],
|
|
userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll
|
|
infoUpdates: 0 // TODO: Add with SOGS V4.
|
|
).insert(db)
|
|
}
|
|
}
|
|
|
|
try autoreleasepool {
|
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
|
|
try interactions[legacyThreadId]?
|
|
.sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order
|
|
.forEach { legacyInteraction in
|
|
let serverHash: String?
|
|
let variant: Interaction.Variant
|
|
let authorId: String
|
|
let body: String?
|
|
let wasRead: Bool
|
|
let expiresInSeconds: UInt32?
|
|
let expiresStartedAtMs: UInt64?
|
|
let openGroupServerMessageId: UInt64?
|
|
let recipientStateMap: [String: TSOutgoingMessageRecipientState]?
|
|
let mostRecentFailureText: String?
|
|
let quotedMessage: TSQuotedMessage?
|
|
let linkPreview: OWSLinkPreview?
|
|
let linkPreviewVariant: LinkPreview.Variant
|
|
var attachmentIds: [String]
|
|
|
|
// Handle the common 'TSMessage' values first
|
|
if let legacyMessage: TSMessage = legacyInteraction as? TSMessage {
|
|
serverHash = legacyMessage.serverHash
|
|
|
|
// The legacy code only considered '!= 0' ids as valid so set those
|
|
// values to be null to avoid the unique constraint (it's also more
|
|
// correct for the values to be null)
|
|
openGroupServerMessageId = (legacyMessage.openGroupServerMessageID == 0 ?
|
|
nil :
|
|
legacyMessage.openGroupServerMessageID
|
|
)
|
|
quotedMessage = legacyMessage.quotedMessage
|
|
|
|
// Convert the 'OpenGroupInvitation' into a LinkPreview
|
|
if let openGroupInvitationName: String = legacyMessage.openGroupInvitationName, let openGroupInvitationUrl: String = legacyMessage.openGroupInvitationURL {
|
|
linkPreviewVariant = .openGroupInvitation
|
|
linkPreview = OWSLinkPreview(
|
|
urlString: openGroupInvitationUrl,
|
|
title: openGroupInvitationName,
|
|
imageAttachmentId: nil
|
|
)
|
|
}
|
|
else {
|
|
linkPreviewVariant = .standard
|
|
linkPreview = legacyMessage.linkPreview
|
|
}
|
|
|
|
// Attachments for deleted messages won't exist
|
|
attachmentIds = (legacyMessage.isDeleted ?
|
|
[] :
|
|
try legacyMessage.attachmentIds.map { legacyId in
|
|
guard let attachmentId: String = legacyId as? String else {
|
|
SNLog("[Migration Error] Unable to process attachment id")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
return attachmentId
|
|
}
|
|
)
|
|
}
|
|
else {
|
|
serverHash = nil
|
|
openGroupServerMessageId = nil
|
|
quotedMessage = nil
|
|
linkPreviewVariant = .standard
|
|
linkPreview = nil
|
|
attachmentIds = []
|
|
}
|
|
|
|
// Then handle the behaviours for each message type
|
|
switch legacyInteraction {
|
|
case let incomingMessage as TSIncomingMessage:
|
|
// Note: We want to distinguish deleted messages from normal ones
|
|
variant = (incomingMessage.isDeleted ?
|
|
.standardIncomingDeleted :
|
|
.standardIncoming
|
|
)
|
|
authorId = incomingMessage.authorId
|
|
body = incomingMessage.body
|
|
wasRead = incomingMessage.wasRead
|
|
expiresInSeconds = incomingMessage.expiresInSeconds
|
|
expiresStartedAtMs = incomingMessage.expireStartedAt
|
|
recipientStateMap = [:]
|
|
mostRecentFailureText = nil
|
|
|
|
case let outgoingMessage as TSOutgoingMessage:
|
|
variant = .standardOutgoing
|
|
authorId = currentUserPublicKey
|
|
body = outgoingMessage.body
|
|
wasRead = true // Outgoing messages are read by default
|
|
expiresInSeconds = outgoingMessage.expiresInSeconds
|
|
expiresStartedAtMs = outgoingMessage.expireStartedAt
|
|
recipientStateMap = outgoingMessage.recipientStateMap
|
|
mostRecentFailureText = outgoingMessage.mostRecentFailureText
|
|
|
|
case let infoMessage as TSInfoMessage:
|
|
// Note: The legacy 'TSInfoMessage' didn't store the author id so there is no
|
|
// way to determine who actually triggered the info message
|
|
authorId = currentUserPublicKey
|
|
body = {
|
|
// Note: The 'DisappearingConfigurationUpdateInfoMessage' stored additional info and constructed
|
|
// a string at display time so we want to continue that behaviour
|
|
guard
|
|
infoMessage.messageType == .disappearingMessagesUpdate,
|
|
let updateMessage: Legacy.DisappearingConfigurationUpdateInfoMessage = infoMessage as? Legacy.DisappearingConfigurationUpdateInfoMessage,
|
|
let infoMessageData: Data = try? JSONEncoder().encode(
|
|
DisappearingMessagesConfiguration.MessageInfo(
|
|
senderName: updateMessage.createdByRemoteName,
|
|
isEnabled: updateMessage.configurationIsEnabled,
|
|
durationSeconds: TimeInterval(updateMessage.configurationDurationSeconds)
|
|
)
|
|
),
|
|
let infoMessageString: String = String(data: infoMessageData, encoding: .utf8)
|
|
else {
|
|
return ((infoMessage.body ?? "").isEmpty ?
|
|
infoMessage.customMessage :
|
|
infoMessage.body
|
|
)
|
|
}
|
|
|
|
return infoMessageString
|
|
}()
|
|
wasRead = infoMessage.wasRead
|
|
expiresInSeconds = nil // Info messages don't expire
|
|
expiresStartedAtMs = nil // Info messages don't expire
|
|
recipientStateMap = [:]
|
|
mostRecentFailureText = nil
|
|
|
|
switch infoMessage.messageType {
|
|
case .groupCreated: variant = .infoClosedGroupCreated
|
|
case .groupUpdated: variant = .infoClosedGroupUpdated
|
|
case .groupCurrentUserLeft: variant = .infoClosedGroupCurrentUserLeft
|
|
case .disappearingMessagesUpdate: variant = .infoDisappearingMessagesUpdate
|
|
case .messageRequestAccepted: variant = .infoMessageRequestAccepted
|
|
case .screenshotNotification: variant = .infoScreenshotNotification
|
|
case .mediaSavedNotification: variant = .infoMediaSavedNotification
|
|
|
|
@unknown default:
|
|
SNLog("[Migration Error] Unsupported info message type")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
default:
|
|
// TODO: What message types have no body?
|
|
SNLog("[Migration Error] Unsupported interaction type")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
// Insert the data
|
|
let interaction: Interaction = try Interaction(
|
|
serverHash: {
|
|
switch variant {
|
|
// Don't store the 'serverHash' for these so sync messages
|
|
// are seen as duplicates
|
|
case .infoDisappearingMessagesUpdate: return nil
|
|
|
|
default: return serverHash
|
|
}
|
|
}(),
|
|
threadId: threadId,
|
|
authorId: authorId,
|
|
variant: variant,
|
|
body: body,
|
|
timestampMs: Int64(legacyInteraction.timestamp),
|
|
receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp),
|
|
wasRead: wasRead,
|
|
hasMention: (
|
|
body?.contains("@\(currentUserPublicKey)") == true ||
|
|
quotedMessage?.authorId == currentUserPublicKey
|
|
),
|
|
// For both of these '0' used to be equivalent to null
|
|
expiresInSeconds: ((expiresInSeconds ?? 0) > 0 ?
|
|
expiresInSeconds.map { TimeInterval($0) } :
|
|
nil
|
|
),
|
|
expiresStartedAtMs: ((expiresStartedAtMs ?? 0) > 0 ?
|
|
expiresStartedAtMs.map { Double($0) } :
|
|
nil
|
|
),
|
|
linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set
|
|
openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) },
|
|
openGroupWhisperMods: false, // TODO: This in SOGSV4
|
|
openGroupWhisperTo: nil // TODO: This in SOGSV4
|
|
).inserted(db)
|
|
|
|
// Insert a 'ControlMessageProcessRecord' if needed (for duplication prevention)
|
|
try ControlMessageProcessRecord(
|
|
threadId: threadId,
|
|
variant: variant,
|
|
timestampMs: Int64(legacyInteraction.timestamp)
|
|
)?.insert(db)
|
|
|
|
// Remove timestamps we created records for (they will be protected by unique
|
|
// constraints so don't need legacy process records)
|
|
receivedMessageTimestamps.remove(legacyInteraction.timestamp)
|
|
|
|
guard let interactionId: Int64 = interaction.id else {
|
|
// TODO: Is it possible the old database has duplicates which could hit this case?
|
|
SNLog("[Migration Error] Failed to insert interaction")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
// Store the interactionId in the lookup map to simplify job creation later
|
|
let legacyIdentifier: String = identifier(
|
|
for: threadId,
|
|
sentTimestamp: legacyInteraction.timestamp,
|
|
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []),
|
|
destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil),
|
|
variant: variant,
|
|
useFallback: false
|
|
)
|
|
let legacyIdentifierFallback: String = identifier(
|
|
for: threadId,
|
|
sentTimestamp: legacyInteraction.timestamp,
|
|
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []),
|
|
destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil),
|
|
variant: variant,
|
|
useFallback: true
|
|
)
|
|
|
|
legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId
|
|
legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId
|
|
legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = interactionId
|
|
|
|
// Handle the recipient states
|
|
|
|
// Note: Inserting an Interaction into the database will automatically create a 'RecipientState'
|
|
// for outgoing messages
|
|
try recipientStateMap?.forEach { recipientId, legacyState in
|
|
try RecipientState(
|
|
interactionId: interactionId,
|
|
recipientId: recipientId,
|
|
state: {
|
|
switch legacyState.state {
|
|
case .failed: return .failed
|
|
case .sending: return .sending
|
|
case .skipped: return .skipped
|
|
case .sent: return .sent
|
|
@unknown default: throw GRDBStorageError.migrationFailed
|
|
}
|
|
}(),
|
|
readTimestampMs: legacyState.readTimestamp?.int64Value,
|
|
mostRecentFailureText: (legacyState.state == .failed ?
|
|
mostRecentFailureText :
|
|
nil
|
|
)
|
|
).save(db)
|
|
}
|
|
|
|
// Handle any quote
|
|
|
|
if let quotedMessage: TSQuotedMessage = quotedMessage {
|
|
var quoteAttachmentId: String? = quotedMessage.quotedAttachments
|
|
.flatMap { attachmentInfo in
|
|
return [
|
|
// Prioritise the thumbnail as it means we won't
|
|
// need to generate a new one
|
|
attachmentInfo.thumbnailAttachmentStreamId,
|
|
attachmentInfo.thumbnailAttachmentPointerId,
|
|
attachmentInfo.attachmentId
|
|
]
|
|
.compactMap { $0 }
|
|
}
|
|
.first { attachmentId -> Bool in attachments[attachmentId] != nil }
|
|
|
|
// It looks like there can be cases where a quote can be quoting an
|
|
// interaction that isn't associated with a profile we know about (eg.
|
|
// if you join an open group and one of the first messages is a quote of
|
|
// an older message not cached to the device) - this will cause a foreign
|
|
// key constraint violation so in these cases just create an empty profile
|
|
if !validProfileIds.contains(quotedMessage.authorId) {
|
|
SNLog("[Migration Warning] Quote with unknown author found - Creating empty profile")
|
|
|
|
// Note: Need to upsert here because it's possible multiple quotes
|
|
// will use the same invalid 'authorId' value resulting in a unique
|
|
// constraint violation
|
|
try Profile(
|
|
id: quotedMessage.authorId,
|
|
name: quotedMessage.authorId
|
|
).save(db)
|
|
}
|
|
|
|
// Note: It looks like there is a way for a quote to not have it's
|
|
// associated attachmentId so let's try our best to track down the
|
|
// original interaction and re-create the attachment link before
|
|
// falling back to having no attachment in the quote
|
|
if quoteAttachmentId == nil && !quotedMessage.quotedAttachments.isEmpty {
|
|
quoteAttachmentId = interactions[legacyThreadId]?
|
|
.first(where: {
|
|
$0.timestamp == quotedMessage.timestamp &&
|
|
(
|
|
// Outgoing messages don't store the 'authorId' so we
|
|
// need to compare against the 'currentUserPublicKey'
|
|
// for those or cast to a TSIncomingMessage otherwise
|
|
quotedMessage.authorId == currentUserPublicKey ||
|
|
quotedMessage.authorId == ($0 as? TSIncomingMessage)?.authorId
|
|
)
|
|
})
|
|
.asType(TSMessage.self)?
|
|
.attachmentIds
|
|
.firstObject
|
|
.asType(String.self)
|
|
|
|
SNLog([
|
|
"[Migration Warning] Quote with invalid attachmentId found",
|
|
(quoteAttachmentId == nil ?
|
|
"Unable to reconcile, leaving attachment blank" :
|
|
"Original interaction found, using source attachment"
|
|
)
|
|
].joined(separator: " - "))
|
|
}
|
|
|
|
// Setup the attachment and add it to the lookup (if it exists)
|
|
let attachmentId: String? = try attachmentId(
|
|
db,
|
|
for: quoteAttachmentId,
|
|
isQuotedMessage: true,
|
|
attachments: attachments,
|
|
processedAttachmentIds: &processedAttachmentIds
|
|
)
|
|
|
|
// Create the quote
|
|
try Quote(
|
|
interactionId: interactionId,
|
|
authorId: quotedMessage.authorId,
|
|
timestampMs: Int64(quotedMessage.timestamp),
|
|
body: quotedMessage.body,
|
|
attachmentId: attachmentId
|
|
).insert(db)
|
|
}
|
|
|
|
// Handle any LinkPreview
|
|
|
|
if let linkPreview: OWSLinkPreview = linkPreview, let urlString: String = linkPreview.urlString {
|
|
// Note: The `legacyInteraction.timestamp` value is in milliseconds
|
|
let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp))
|
|
|
|
guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else {
|
|
// TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded?
|
|
SNLog("[Migration Error] Missing link preview attachment")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
// Setup the attachment and add it to the lookup (if it exists)
|
|
let attachmentId: String? = try attachmentId(
|
|
db,
|
|
for: linkPreview.imageAttachmentId,
|
|
attachments: attachments,
|
|
processedAttachmentIds: &processedAttachmentIds
|
|
)
|
|
|
|
// Note: It's possible for there to be duplicate values here so we use 'save'
|
|
// instead of insert (ie. upsert)
|
|
try LinkPreview(
|
|
url: urlString,
|
|
timestamp: timestamp,
|
|
variant: linkPreviewVariant,
|
|
title: linkPreview.title,
|
|
attachmentId: attachmentId
|
|
).save(db)
|
|
}
|
|
|
|
// Handle any attachments
|
|
|
|
try attachmentIds.forEach { legacyAttachmentId in
|
|
guard let attachmentId: String = try attachmentId(
|
|
db,
|
|
for: legacyAttachmentId,
|
|
interactionVariant: variant,
|
|
attachments: attachments,
|
|
processedAttachmentIds: &processedAttachmentIds
|
|
) else {
|
|
SNLog("[Migration Error] Missing interaction attachment")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
// Link the attachment to the interaction and add to the id lookup
|
|
try InteractionAttachment(
|
|
interactionId: interactionId,
|
|
attachmentId: attachmentId
|
|
).insert(db)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp'
|
|
// entries as "legacy"
|
|
try ControlMessageProcessRecord.generateLegacyProcessRecords(
|
|
db,
|
|
receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) }
|
|
)
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End")
|
|
|
|
// Clear out processed data (give the memory a change to be freed)
|
|
|
|
contacts = []
|
|
contactThreadIds = []
|
|
|
|
threads = []
|
|
disappearingMessagesConfiguration = [:]
|
|
|
|
closedGroupKeys = [:]
|
|
closedGroupName = [:]
|
|
closedGroupFormation = [:]
|
|
closedGroupModel = [:]
|
|
closedGroupZombieMemberIds = [:]
|
|
|
|
openGroupInfo = [:]
|
|
openGroupUserCount = [:]
|
|
openGroupImage = [:]
|
|
openGroupLastMessageServerId = [:]
|
|
openGroupLastDeletionServerId = [:]
|
|
|
|
interactions = [:]
|
|
attachments = [:]
|
|
receivedMessageTimestamps = []
|
|
|
|
// MARK: - Process Legacy Jobs
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - Start")
|
|
|
|
var notifyPushServerJobs: Set<Legacy.NotifyPNServerJob> = []
|
|
var messageReceiveJobs: Set<Legacy.MessageReceiveJob> = []
|
|
var messageSendJobs: Set<Legacy.MessageSendJob> = []
|
|
var attachmentUploadJobs: Set<Legacy.AttachmentUploadJob> = []
|
|
var attachmentDownloadJobs: Set<Legacy.AttachmentDownloadJob> = []
|
|
|
|
// Map the Legacy types for the NSKeyedUnarchiver
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.NotifyPNServerJob.self,
|
|
forClassName: "SessionMessagingKit.NotifyPNServerJob"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.NotifyPNServerJob.SnodeMessage.self,
|
|
forClassName: "SessionSnodeKit.SnodeMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.MessageSendJob.self,
|
|
forClassName: "SessionMessagingKit.SNMessageSendJob"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.MessageReceiveJob.self,
|
|
forClassName: "SessionMessagingKit.MessageReceiveJob"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.AttachmentUploadJob.self,
|
|
forClassName: "SessionMessagingKit.AttachmentUploadJob"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.AttachmentDownloadJob.self,
|
|
forClassName: "SessionMessagingKit.AttachmentDownloadJob"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.Message.self,
|
|
forClassName: "SNMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.VisibleMessage.self,
|
|
forClassName: "SNVisibleMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.Quote.self,
|
|
forClassName: "SNQuote"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.LinkPreview.self,
|
|
forClassName: "SessionServiceKit.OWSLinkPreview" // Very old legacy name
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.LinkPreview.self,
|
|
forClassName: "SNLinkPreview"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.Profile.self,
|
|
forClassName: "SNProfile"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.OpenGroupInvitation.self,
|
|
forClassName: "SNOpenGroupInvitation"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ControlMessage.self,
|
|
forClassName: "SNControlMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ReadReceipt.self,
|
|
forClassName: "SNReadReceipt"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.TypingIndicator.self,
|
|
forClassName: "SNTypingIndicator"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ClosedGroupControlMessage.self,
|
|
forClassName: "SessionMessagingKit.ClosedGroupControlMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ClosedGroupControlMessage.KeyPairWrapper.self,
|
|
forClassName: "ClosedGroupControlMessage.SNKeyPairWrapper"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.DataExtractionNotification.self,
|
|
forClassName: "SessionMessagingKit.DataExtractionNotification"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ExpirationTimerUpdate.self,
|
|
forClassName: "SNExpirationTimerUpdate"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.ConfigurationMessage.self,
|
|
forClassName: "SNConfigurationMessage"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.CMClosedGroup.self,
|
|
forClassName: "SNClosedGroup"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.CMContact.self,
|
|
forClassName: "SNConfigurationMessage.SNConfigurationMessageContact"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.UnsendRequest.self,
|
|
forClassName: "SNUnsendRequest"
|
|
)
|
|
NSKeyedUnarchiver.setClass(
|
|
Legacy.MessageRequestResponse.self,
|
|
forClassName: "SNMessageRequestResponse"
|
|
)
|
|
|
|
Storage.read { transaction in
|
|
transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in
|
|
guard let job = object as? Legacy.NotifyPNServerJob else { return }
|
|
notifyPushServerJobs.insert(job)
|
|
}
|
|
|
|
transaction.enumerateRows(inCollection: Legacy.messageReceiveJobCollection) { _, object, _, _ in
|
|
guard let job = object as? Legacy.MessageReceiveJob else { return }
|
|
messageReceiveJobs.insert(job)
|
|
}
|
|
|
|
transaction.enumerateRows(inCollection: Legacy.messageSendJobCollection) { _, object, _, _ in
|
|
guard let job = object as? Legacy.MessageSendJob else { return }
|
|
messageSendJobs.insert(job)
|
|
}
|
|
|
|
transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in
|
|
guard let job = object as? Legacy.AttachmentUploadJob else { return }
|
|
attachmentUploadJobs.insert(job)
|
|
}
|
|
|
|
transaction.enumerateRows(inCollection: Legacy.attachmentDownloadJobCollection) { _, object, _, _ in
|
|
guard let job = object as? Legacy.AttachmentDownloadJob else { return }
|
|
attachmentDownloadJobs.insert(job)
|
|
}
|
|
}
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process jobs - End")
|
|
|
|
// MARK: - Insert Jobs
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - Start")
|
|
|
|
// MARK: - --notifyPushServer
|
|
|
|
try autoreleasepool {
|
|
try notifyPushServerJobs.forEach { legacyJob in
|
|
_ = try Job(
|
|
failureCount: legacyJob.failureCount,
|
|
variant: .notifyPushServer,
|
|
behaviour: .runOnce,
|
|
nextRunTimestamp: 0,
|
|
details: NotifyPushServerJob.Details(
|
|
message: SnodeMessage(
|
|
recipient: legacyJob.message.recipient,
|
|
// Note: The legacy type had 'LosslessStringConvertible' so we need
|
|
// to use '.description' to get it as a basic string
|
|
data: legacyJob.message.data.description,
|
|
ttl: legacyJob.message.ttl,
|
|
timestampMs: legacyJob.message.timestamp
|
|
)
|
|
)
|
|
)?.inserted(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - --messageReceive
|
|
|
|
try autoreleasepool {
|
|
try messageReceiveJobs.forEach { legacyJob in
|
|
// We haven't supported OpenGroup messageReceive jobs for a long time so if
|
|
// we see any then just ignore them
|
|
if legacyJob.openGroupID != nil && legacyJob.openGroupMessageServerID != nil {
|
|
return
|
|
}
|
|
|
|
// We need to extract the `threadId` from the legacyJob data as the new
|
|
// MessageReceiveJob requires it for multi-threading and garbage collection purposes
|
|
guard let envelope: SNProtoEnvelope = try? SNProtoEnvelope.parseData(legacyJob.data) else {
|
|
return
|
|
}
|
|
|
|
let threadId: String?
|
|
|
|
switch envelope.type {
|
|
// For closed group messages the 'groupPublicKey' is stored in the
|
|
// 'envelope.source' value and that should be used for the 'threadId'
|
|
case .closedGroupMessage:
|
|
threadId = envelope.source
|
|
break
|
|
|
|
default:
|
|
threadId = MessageReceiver.extractSenderPublicKey(db, from: envelope)
|
|
}
|
|
|
|
_ = try Job(
|
|
failureCount: legacyJob.failureCount,
|
|
variant: .messageReceive,
|
|
behaviour: .runOnce,
|
|
nextRunTimestamp: 0,
|
|
threadId: threadId,
|
|
details: MessageReceiveJob.Details(
|
|
messages: [
|
|
MessageReceiveJob.Details.MessageInfo(
|
|
data: legacyJob.data,
|
|
serverHash: legacyJob.serverHash,
|
|
serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds)
|
|
)
|
|
],
|
|
isBackgroundPoll: legacyJob.isBackgroundPoll
|
|
)
|
|
)?.inserted(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - --messageSend
|
|
|
|
var messageSendJobIdMap: [String: Int64] = [:]
|
|
|
|
try autoreleasepool {
|
|
try messageSendJobs.forEach { legacyJob in
|
|
// Fetch the threadId and interactionId this job should be associated with
|
|
let threadId: String = {
|
|
switch legacyJob.destination {
|
|
case .contact(let publicKey): return publicKey
|
|
case .closedGroup(let groupPublicKey): return groupPublicKey
|
|
case .openGroupV2(let room, let server):
|
|
return OpenGroup.idFor(room: room, server: server)
|
|
|
|
case .openGroup: return ""
|
|
}
|
|
}()
|
|
let interactionId: Int64? = {
|
|
// The 'Legacy.Job' 'id' value was "(timestamp)(num jobs for this timestamp)"
|
|
// so we can reverse-engineer an approximate timestamp by extracting it from
|
|
// the id (this value is unlikely to match exactly though)
|
|
let fallbackTimestamp: UInt64 = legacyJob.id
|
|
.map { UInt64($0.prefix("\(Int(Date().timeIntervalSince1970 * 1000))".count)) }
|
|
.defaulting(to: 0)
|
|
let legacyIdentifier: String = identifier(
|
|
for: threadId,
|
|
sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp),
|
|
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
|
|
destination: legacyJob.destination,
|
|
variant: nil,
|
|
useFallback: false
|
|
)
|
|
|
|
if let matchingId: Int64 = legacyInteractionIdentifierToIdMap[legacyIdentifier] {
|
|
return matchingId
|
|
}
|
|
|
|
// If we didn't find the correct interaction then we need to try the "fallback"
|
|
// identifier which is less accurate (during testing this only happened for
|
|
// 'ExpirationTimerUpdate' send jobs)
|
|
let fallbackIdentifier: String = identifier(
|
|
for: threadId,
|
|
sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp),
|
|
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
|
|
destination: legacyJob.destination,
|
|
variant: {
|
|
switch legacyJob.message {
|
|
case is Legacy.ExpirationTimerUpdate:
|
|
return .infoDisappearingMessagesUpdate
|
|
default: return nil
|
|
}
|
|
}(),
|
|
useFallback: true
|
|
)
|
|
|
|
return legacyInteractionIdentifierToIdFallbackMap[fallbackIdentifier]
|
|
}()
|
|
|
|
let job: Job? = try Job(
|
|
failureCount: legacyJob.failureCount,
|
|
variant: .messageSend,
|
|
behaviour: .runOnce,
|
|
nextRunTimestamp: 0,
|
|
threadId: threadId,
|
|
// Note: There are some cases where there isn't a link between a
|
|
// 'MessageSendJob' and an interaction (eg. ConfigurationMessage),
|
|
// in these cases the 'interactionId' value will be nil
|
|
interactionId: interactionId,
|
|
details: MessageSendJob.Details(
|
|
destination: legacyJob.destination,
|
|
message: legacyJob.message.toNonLegacy()
|
|
)
|
|
)?.inserted(db)
|
|
|
|
if let oldId: String = legacyJob.id, let newId: Int64 = job?.id {
|
|
messageSendJobIdMap[oldId] = newId
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - --attachmentUpload
|
|
|
|
try autoreleasepool {
|
|
try attachmentUploadJobs.forEach { legacyJob in
|
|
guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else {
|
|
SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
_ = try Job(
|
|
failureCount: legacyJob.failureCount,
|
|
variant: .attachmentUpload,
|
|
behaviour: .runOnce,
|
|
nextRunTimestamp: 0,
|
|
details: AttachmentUploadJob.Details(
|
|
threadId: legacyJob.threadID,
|
|
attachmentId: legacyJob.attachmentID,
|
|
messageSendJobId: sendJobId
|
|
)
|
|
)?.inserted(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - --attachmentDownload
|
|
|
|
try autoreleasepool {
|
|
try attachmentDownloadJobs.forEach { legacyJob in
|
|
guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else {
|
|
SNLog("[Migration Error] attachmentDownload job unable to find interaction")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
guard processedAttachmentIds.contains(legacyJob.attachmentID) else {
|
|
SNLog("[Migration Error] attachmentDownload job unable to find attachment")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
_ = try Job(
|
|
failureCount: legacyJob.failureCount,
|
|
variant: .attachmentDownload,
|
|
behaviour: .runOnce,
|
|
nextRunTimestamp: 0,
|
|
threadId: legacyThreadIdToIdMap[legacyJob.threadID],
|
|
interactionId: interactionId,
|
|
details: AttachmentDownloadJob.Details(
|
|
attachmentId: legacyJob.attachmentID
|
|
)
|
|
)?.inserted(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - --sendReadReceipts
|
|
|
|
try autoreleasepool {
|
|
try outgoingReadReceiptsTimestampsMs.forEach { threadId, timestampsMs in
|
|
_ = try Job(
|
|
variant: .sendReadReceipts,
|
|
behaviour: .recurring,
|
|
threadId: threadId,
|
|
details: SendReadReceiptsJob.Details(
|
|
destination: .contact(publicKey: threadId),
|
|
timestampMsValues: timestampsMs
|
|
)
|
|
)?.inserted(db)
|
|
}
|
|
}
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process job inserts - End")
|
|
|
|
// MARK: - Process Preferences
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - Start")
|
|
|
|
var legacyPreferences: [String: Any] = [:]
|
|
|
|
Storage.read { transaction in
|
|
transaction.enumerateKeysAndObjects(inCollection: Legacy.preferencesCollection) { key, object, _ in
|
|
legacyPreferences[key] = object
|
|
}
|
|
|
|
// Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value for the notification
|
|
// sound so catch it and default
|
|
let globalNotificationSoundValue: Int32 = transaction.int(
|
|
forKey: Legacy.soundsGlobalNotificationKey,
|
|
inCollection: Legacy.soundsStorageNotificationCollection
|
|
)
|
|
legacyPreferences[Legacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ?
|
|
Int(globalNotificationSoundValue) :
|
|
Preferences.Sound.defaultNotificationSound.rawValue
|
|
)
|
|
|
|
legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = (transaction.bool(
|
|
forKey: Legacy.readReceiptManagerAreReadReceiptsEnabled,
|
|
inCollection: Legacy.readReceiptManagerCollection,
|
|
defaultValue: false
|
|
) ? 1 : 0)
|
|
|
|
legacyPreferences[Legacy.typingIndicatorsEnabledKey] = (transaction.bool(
|
|
forKey: Legacy.typingIndicatorsEnabledKey,
|
|
inCollection: Legacy.typingIndicatorsCollection,
|
|
defaultValue: false
|
|
) ? 1 : 0)
|
|
}
|
|
|
|
db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1)
|
|
.defaulting(to: .nameAndPreview)
|
|
db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[Legacy.soundsGlobalNotificationKey] as? Int ?? -1)
|
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
|
|
|
if let lastPushToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedPushToken] as? String {
|
|
db[.lastRecordedPushToken] = lastPushToken
|
|
}
|
|
|
|
if let lastVoipToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedVoipToken] as? String {
|
|
db[.lastRecordedVoipToken] = lastVoipToken
|
|
}
|
|
|
|
// Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the setting
|
|
// was disabled, this has been inverted to 'preferencesAppSwitcherPreviewEnabled' so it can default
|
|
// to 'false' (as most Bool values do)
|
|
db[.preferencesAppSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false)
|
|
db[.areReadReceiptsEnabled] = (legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true)
|
|
db[.typingIndicatorsEnabled] = (legacyPreferences[Legacy.typingIndicatorsEnabledKey] as? Bool == true)
|
|
|
|
db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults()
|
|
.bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests)
|
|
|
|
print("RAWR [\(Date().timeIntervalSince1970)] - Process preferences inserts - End")
|
|
|
|
print("RAWR Done!!!")
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private static func attachmentId(
|
|
_ db: Database,
|
|
for legacyAttachmentId: String?,
|
|
interactionVariant: Interaction.Variant? = nil,
|
|
isQuotedMessage: Bool = false,
|
|
attachments: [String: Legacy.Attachment],
|
|
processedAttachmentIds: inout Set<String>
|
|
) throws -> String? {
|
|
guard let legacyAttachmentId: String = legacyAttachmentId else { return nil }
|
|
guard !processedAttachmentIds.contains(legacyAttachmentId) else {
|
|
guard isQuotedMessage else {
|
|
SNLog("[Migration Error] Attempted to process duplicate attachment")
|
|
throw GRDBStorageError.migrationFailed
|
|
}
|
|
|
|
return legacyAttachmentId
|
|
}
|
|
|
|
guard let legacyAttachment: Legacy.Attachment = attachments[legacyAttachmentId] else {
|
|
SNLog("[Migration Warning] Missing attachment - interaction will appear as blank")
|
|
return nil
|
|
}
|
|
|
|
let state: Attachment.State = {
|
|
switch legacyAttachment {
|
|
case let stream as Legacy.AttachmentStream: // Outgoing or already downloaded
|
|
switch interactionVariant {
|
|
case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending)
|
|
default: return .downloaded
|
|
}
|
|
|
|
// All other cases can just be set to 'pending'
|
|
default: return .pending
|
|
}
|
|
}()
|
|
let size: CGSize = {
|
|
switch legacyAttachment {
|
|
case let stream as Legacy.AttachmentStream:
|
|
guard let originalFilePath: String = Attachment.originalFilePath(id: legacyAttachmentId, mimeType: stream.contentType, sourceFilename: stream.localRelativeFilePath) else {
|
|
return .zero
|
|
}
|
|
|
|
return Attachment
|
|
.imageSize(
|
|
contentType: stream.contentType,
|
|
originalFilePath: originalFilePath
|
|
)
|
|
.defaulting(to: .zero)
|
|
|
|
case let pointer as Legacy.AttachmentPointer: return pointer.mediaSize
|
|
default: return CGSize.zero
|
|
}
|
|
}()
|
|
let (isValid, duration): (Bool, TimeInterval?) = {
|
|
guard
|
|
let stream: Legacy.AttachmentStream = legacyAttachment as? Legacy.AttachmentStream,
|
|
let originalFilePath: String = Attachment.originalFilePath(
|
|
id: legacyAttachmentId,
|
|
mimeType: stream.contentType,
|
|
sourceFilename: stream.localRelativeFilePath
|
|
)
|
|
else {
|
|
return (false, nil)
|
|
}
|
|
|
|
if stream.isAudio {
|
|
if let cachedDuration: TimeInterval = stream.cachedAudioDurationSeconds?.doubleValue, cachedDuration > 0 {
|
|
return (true, cachedDuration)
|
|
}
|
|
|
|
let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration(
|
|
contentType: stream.contentType,
|
|
originalFilePath: originalFilePath
|
|
)
|
|
|
|
return (isValid, duration)
|
|
}
|
|
|
|
if stream.isVideo {
|
|
let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath))
|
|
let duration: TimeInterval? = videoPlayer.currentItem
|
|
.map { item -> TimeInterval in
|
|
// Accorting to the CMTime docs "value/timescale = seconds"
|
|
(TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale))
|
|
}
|
|
|
|
return ((duration ?? 0) > 0, duration)
|
|
}
|
|
|
|
if stream.isVisualMedia {
|
|
return (stream.isValidVisualMedia, nil)
|
|
}
|
|
|
|
return (true, nil)
|
|
}()
|
|
|
|
|
|
_ = try Attachment(
|
|
// Note: The legacy attachment object used a UUID string for it's id as well
|
|
// and saved files using these id's so just used the existing id so we don't
|
|
// need to bother renaming files as part of the migration
|
|
id: legacyAttachmentId,
|
|
serverId: "\(legacyAttachment.serverId)",
|
|
variant: (legacyAttachment.attachmentType == .voiceMessage ? .voiceMessage : .standard),
|
|
state: state,
|
|
contentType: legacyAttachment.contentType,
|
|
byteCount: UInt(legacyAttachment.byteCount),
|
|
creationTimestamp: (legacyAttachment as? Legacy.AttachmentStream)?.creationTimestamp.timeIntervalSince1970,
|
|
sourceFilename: legacyAttachment.sourceFilename,
|
|
downloadUrl: legacyAttachment.downloadURL,
|
|
width: (size == .zero ? nil : UInt(size.width)),
|
|
height: (size == .zero ? nil : UInt(size.height)),
|
|
duration: duration,
|
|
isValid: isValid,
|
|
encryptionKey: legacyAttachment.encryptionKey,
|
|
digest: (legacyAttachment as? Legacy.AttachmentStream)?.digest,
|
|
caption: legacyAttachment.caption
|
|
).inserted(db)
|
|
|
|
processedAttachmentIds.insert(legacyAttachmentId)
|
|
|
|
return legacyAttachmentId
|
|
}
|
|
}
|