import CocoaLumberjackSwift
import FileUtility
import Foundation
import Keychain
import OSLog
import ThreemaEssentials
import ThreemaFramework
import ThreemaMacros
/// Migrate app to a new version
///
/// How to add a new migration:
///
/// 1. Add a new `AppMigrationVersion`. See its documentation for details
/// 2. Add a new migrate function (`migrateToX_Y`)
/// - Document the function and describe what is migrated
/// - Check if the migration is as fast as possible and reduces resource usage (e.g. memory): E.g. for Core Data
/// operations use batch (`NSBatch...`) and try to load the least amount of Core Data-Objects possible.
/// 3. Call the migration in `run()`
/// 4. Extend the `AppMigrationTests` with the new migration
///
/// ```swift
/// ...
/// func run() {
/// do {
/// if migratedTo < .v4_8 {
/// try migrateTo4_8()
/// migratedTo = .v4_8
/// }
/// if migratedTo < .v4_9 {
/// try migratedTo4_9()
/// migratedTo = .v4_9
/// }
/// ...
///
/// /// Migrate to version 4.9:
/// /// Describe migration steps here!
/// private func migrateTo4_9() throws {
/// DDLogNotice("[AppMigration] App migration to version 4.9 started")
/// os_signpost(.begin, log: osPOILog, name: "4.9 migration")
/// ...
/// os_signpost(.end, log: osPOILog, name: "4.9 migration")
/// DDLogNotice("[AppMigration] App migration to version 4.9 successfully finished")
/// }
/// ```
final class AppMigration {
/// Where the push notification encryption key was stored before the migration to 7.4
static let legacyPushEncryptionKeyUserDefaultsKey = "PushNotificationEncryptionKey"
private let osPOILog = OSLog(subsystem: "ch.threema.iapp.appMigration", category: .pointsOfInterest)
// BusinessInjector for main or background thread
private let businessInjector: BusinessInjectorProtocol
private var migratedTo: AppMigrationVersion {
get {
AppMigrationVersion(rawValue: businessInjector.userSettings.appMigratedToVersion) ?? .none
}
set {
businessInjector.userSettings.appMigratedToVersion = newValue.rawValue
}
}
private lazy var persistenceMigration = PersistenceMigration(entityManager: businessInjector.entityManager)
init(
businessInjector: BusinessInjectorProtocol,
reset: Bool = false
) {
self.businessInjector = businessInjector
if reset {
businessInjector.userSettings.appMigratedToVersion = AppMigrationVersion.none.rawValue
}
}
convenience init(reset: Bool = false) {
self.init(
businessInjector: BusinessInjector(forBackgroundProcess: !Thread.isMainThread),
reset: reset
)
}
/// Runs all necessary migrations
/// Throws an error if and only if a migration has failed and the app is expected to not be usable without it.
/// Specifically `run` catches all errors but only re-throws a closely defined subset of it
///
/// Errors thrown by `run` are always `NSErrors` i.e. they are directly usable by `ErrorHandler.abortWithError()`
/// function.
func run() throws {
// We do not perform migrations in safe mode
guard !SettingsBundleHelper.safeMode else {
DDLogNotice("[AppMigration] safe mode enabled no migrations will be performed")
return
}
// We need to run this check to reset the latest version to the correct value if needed
guard AppMigrationVersion.isMigrationRequired(userSettings: businessInjector.userSettings) else {
DDLogNotice("[AppMigration] No migration needed")
return
}
// Do app files migration before files might be used by business services
let appFilesMigration = AppFilesMigration(migratedTo: migratedTo)
try appFilesMigration.run()
do {
if migratedTo < .v4_8 {
try migrateTo4_8()
migratedTo = .v4_8
}
if migratedTo < .v5_1 {
try migrateTo5_1()
migratedTo = .v5_1
}
if migratedTo < .v5_2 {
try migrateTo5_2()
migratedTo = .v5_2
}
if migratedTo < .v5_3_1 {
// This migration can throw errors of kind `SQLDHSessionStore.SQLDHSessionStoreMigrationError` which
// are rethrown here
try migrateTo5_3_1()
migratedTo = .v5_3_1
}
if migratedTo < .v5_4 {
// This migration can throw errors of kind `SQLDHSessionStore.SQLDHSessionStoreMigrationError` which
// are rethrown here
try migrateTo5_4()
migratedTo = .v5_4
}
if migratedTo < .v5_5 {
try migrateTo5_5()
migratedTo = .v5_5
}
if migratedTo < .v5_6 {
try migrateTo5_6()
migratedTo = .v5_6
}
if migratedTo < .v5_7 {
try migrateTo5_7()
migratedTo = .v5_7
}
if migratedTo < .v5_9 {
try migrateTo5_9()
migratedTo = .v5_9
}
if migratedTo < .v5_9_2 {
try migrateTo5_9_2()
migratedTo = .v5_9_2
}
if migratedTo < .v6_0 {
try migrateTo6_0()
migratedTo = .v6_0
}
if migratedTo < .v6_2 {
try migrateTo6_2()
migratedTo = .v6_2
}
if migratedTo < .v6_2_1 {
// Only a files migration is run
migratedTo = .v6_2_1
}
if migratedTo < .v6_3 {
// Only a files migration is run
migratedTo = .v6_3
}
if migratedTo < .v6_6 {
try migrateTo6_6()
migratedTo = .v6_6
}
if migratedTo < .v6_8_8 {
try migrateTo6_8_8()
migratedTo = .v6_8_8
}
if migratedTo < .v6_9 {
try migrateTo6_9()
migratedTo = .v6_9
}
if migratedTo < .v7_0_4 {
try migrateTo7_0_4()
migratedTo = .v7_0_4
}
if migratedTo < .v7_1 {
try migrateTo7_1()
migratedTo = .v7_1
}
if migratedTo < .v7_3 {
try migrateTo7_3()
migratedTo = .v7_3
}
if migratedTo < .v7_4 {
try migrateTo7_4()
migratedTo = .v7_4
}
if migratedTo < .v7_5 {
try migrateTo7_5()
migratedTo = .v7_5
}
// Add here a check if migration is necessary for a particular version...
}
catch {
DDLogError("[AppMigration] (last migrated version \(migratedTo)) failed: \(error)")
if let error = error as? SQLDHSessionStore.SQLDHSessionStoreMigrationError {
switch error {
case let .downgradeFromUnsupportedVersion(innerError),
let .unknownError(innerError):
throw innerError
}
}
}
// Validate that we actually upgraded to the most recent version
guard !AppMigrationVersion.isMigrationRequired(userSettings: businessInjector.userSettings) else {
// We need to throw a `NSError` with a localized message shown as part of the error body to the user, but we
// only localize the first sentence such that the rest is easily readable by us.
let dict = [
NSLocalizedDescriptionKey: "\(#localize("app_migration_uncompleted")) Expected: \(AppMigrationVersion.allCases.last!.rawValue) Actual: \(businessInjector.userSettings.appMigratedToVersion)",
]
let nsError = NSError(domain: "\(type(of: self))", code: 1, userInfo: dict)
throw nsError
}
}
func updateAppMigrationVersionToLatest() {
businessInjector.userSettings.appMigratedToVersion = AppMigrationVersion.latestVersion.rawValue
}
// Add here migration function for particular version...
/// Migrate to version 4.8:
/// - Check protection on my identity store
/// - Update all contacts to CNContact
/// - Update all work contacts the verification level `kVerificationLevelWorkVerified` and
/// `kVerificationLevelWorkFullyVerified` to flag `workContact`
/// - Cleanup draft of all conversations
/// - Add push settings for all group conversations
/// - Mark all messages after last read message as read
private func migrateTo4_8() throws {
DDLogNotice("[AppMigration] App migration to version 4.8 started")
os_signpost(.begin, log: osPOILog, name: "4.8 migration")
try businessInjector.keychainManager.migrateToVersion0()
// In earlier versions, when we updated the database from NSFileProtectionComplete to
// NSFileProtectionCompleteUntilFirstUserAuthentication, we did not also update the
// contents of the external data directory. This needs to be done now, because the
// Web client must be able to access images etc. when the device is locked, and Core Data
// needs to save external data files for received media.
// To be sure, we check our entire app and group containers and change any files or directories
// that are still set to NSFileProtectionComplete to the more appropriate
// NSFileProtectionCompleteUntilFirstUserAuthentication.
// Note that directories may have a file protection class (which then applies to all new files
// created within them), but they do not have to.
let fileUtility = FileUtility.shared!
if let appDataDirectory = fileUtility.appDataDirectory(appGroupID: AppGroup.groupID()) {
fileUtility.updateProtectionFormCompleteToCompleteUntilFirstUserAuthentication(at: appDataDirectory)
}
fileUtility.updateProtectionFormCompleteToCompleteUntilFirstUserAuthentication(
at: URL(fileURLWithPath: NSHomeDirectory())
)
businessInjector.contactStore.updateAllContacts()
MessageDraftStore.shared.cleanupDrafts()
AppGroup.userDefaults().removeObject(forKey: "AlreadyDeletedOldDrafts")
businessInjector.entityManager.performAndWaitSave {
for conversation in self.businessInjector.entityManager.entityFetcher.conversationEntities() ?? [] {
let messageFetcher = MessageFetcher(for: conversation, with: self.businessInjector.entityManager)
let calendar = Calendar.current
// Check has conversation unread messages
guard self.businessInjector.entityManager.entityFetcher.unreadMessageCount(for: conversation) > 0
else {
if conversation.unreadMessageCount != 0 {
self.businessInjector.unreadMessages.totalCount(doCalcUnreadMessagesCountOf: [conversation])
}
continue
}
// Get remote date from latest read message or max 2 week old
var firstRemoteSentDate: Date? = calendar.date(byAdding: .day, value: -14, to: Date())
for msg in messageFetcher.messages(at: 0, count: messageFetcher.count()).reversed() {
if !msg.isOwnMessage,
msg.read.boolValue,
let remoteSendDate = msg.remoteSentDate {
if firstRemoteSentDate == nil {
firstRemoteSentDate = remoteSendDate
}
else if remoteSendDate > firstRemoteSentDate! {
firstRemoteSentDate = remoteSendDate
}
break
}
}
let batch = NSBatchUpdateRequest(entityName: "Message")
batch.resultType = .statusOnlyResultType
batch.predicate = NSPredicate(
format: "conversation == %@ && isOwn == false && remoteSentDate < %@", conversation,
firstRemoteSentDate! as NSDate
)
batch.propertiesToUpdate = ["readDate": Date(), "read": true]
// if there was a error, the execute function will return nil or a result with the result 0
if let result = self.businessInjector.entityManager.entityFetcher.execute(batchUpdateRequest: batch) {
if let success = result.result as? Int,
success == 0 {
DDLogError(
"[AppMigration] Failed to set messages as read for conversation \(conversation.objectID)"
)
}
}
else {
DDLogError(
"[AppMigration] Failed to set messages as read for conversation \(conversation.objectID)"
)
}
self.businessInjector.unreadMessages.totalCount(doCalcUnreadMessagesCountOf: [conversation])
}
}
// See IOS-2811 for more information
AppGroup.userDefaults().set(false, forKey: "PushReminderDoNotShowAgain")
os_signpost(.end, log: osPOILog, name: "4.8 migration")
DDLogNotice("[AppMigration] App migration to version 4.8 successfully finished")
}
/// Migrate to version 5.1:
/// - Check pushShowNickname and set the correct notification type
private func migrateTo5_1() throws {
DDLogNotice("[AppMigration] App migration to version 5.1 started")
os_signpost(.begin, log: osPOILog, name: "5.1 migration")
let pushShowNickname = AppGroup.userDefaults().bool(forKey: "PushShowNickname")
if pushShowNickname == true {
UserSettings.shared().notificationType = NSNumber(0)
}
os_signpost(.end, log: osPOILog, name: "5.1 migration")
DDLogNotice("[AppMigration] App migration to version 5.1 successfully finished")
}
/// Migrate to version 5.2:
/// - Replace the conversation.marked property with the conversation.visibility property
private func migrateTo5_2() throws {
DDLogNotice("[AppMigration] App migration to version 5.2 started")
os_signpost(.begin, log: osPOILog, name: "5.2 migration")
businessInjector.entityManager.performAndWaitSave {
let batch = NSBatchUpdateRequest(entityName: "Conversation")
batch.resultType = .statusOnlyResultType
batch.predicate = NSPredicate(
format: "marked == \(NSNumber(booleanLiteral: true))"
)
batch.propertiesToUpdate = [
"marked": NSNumber(booleanLiteral: false),
"visibility": ConversationEntity.Visibility.pinned.rawValue,
]
// if there was a error, the execute function will return nil or a result with the result 0
if let result = self.businessInjector.entityManager.entityFetcher.execute(batchUpdateRequest: batch) {
if let success = result.result as? Int,
success == 0 {
DDLogError(
"[AppMigration] Failed to set visibility for conversations"
)
}
}
else {
DDLogError(
"[AppMigration] Failed to set visibility for conversations"
)
}
}
os_signpost(.end, log: osPOILog, name: "5.2 migration")
DDLogNotice("[AppMigration] App migration to version 5.2 successfully finished")
}
/// Migrate to version 5.3.1:
/// - Update the FS session data base to database version 1
/// This only changes the userVersion field or allows downgrading from version 5.4
private func migrateTo5_3_1() throws {
DDLogNotice("[AppMigration] App migration to version 5.3.1 started")
os_signpost(.begin, log: osPOILog, name: "5.3.1 migration")
try BusinessInjector.ui.dhSessionStore.executeNull()
os_signpost(.end, log: osPOILog, name: "5.3.1 migration")
DDLogNotice("[AppMigration] App migration to version 5.3.1 successfully finished")
}
/// Migrate to version 5.4:
/// - Update the FS session data base to include new fields required in protocol version 18
private func migrateTo5_4() throws {
DDLogNotice("[AppMigration] App migration to version 5.4 started")
os_signpost(.begin, log: osPOILog, name: "5.4 migration")
try BusinessInjector.ui.dhSessionStore.executeNull()
businessInjector.entityManager.performAndWaitSave {
// Set for all conversations the last update
let batch = NSBatchUpdateRequest(entityName: "Conversation")
batch.resultType = .statusOnlyResultType
batch.predicate = NSPredicate(
format: "lastUpdate == nil"
)
batch.propertiesToUpdate = [
"lastUpdate": Date(timeIntervalSince1970: 0),
]
// if there was a error, the execute function will return nil or a result with the result 0
if let result = self.businessInjector.entityManager.entityFetcher.execute(batchUpdateRequest: batch) {
if let success = result.result as? Int,
success == 0 {
DDLogError(
"[AppMigration] Failed to set lastUpdate for empty lastUpdate conversations"
)
}
else {
DDLogNotice(
"[AppMigration] Succeeded to set lastUpdate for empty lastUpdate conversations"
)
}
}
else {
DDLogError(
"[AppMigration] Failed to set lastUpdate for empty lastUpdate conversations"
)
}
}
os_signpost(.end, log: osPOILog, name: "5.4 migration")
DDLogNotice("[AppMigration] App migration to version 5.4 successfully finished")
}
/// Migrate to version 5.5:
/// - Remove own contact from contact list if exists
private func migrateTo5_5() throws {
DDLogNotice("[AppMigration] App migration to version 5.5 started")
os_signpost(.begin, log: osPOILog, name: "5.5 migration")
businessInjector.entityManager.performAndWaitSave {
if self.businessInjector.entityManager.entityDestroyer
.deleteOwnContact(myIdentity: self.businessInjector.myIdentityStore.identity) {
DDLogNotice("[AppMigration] Removed own contact from contact list")
}
}
os_signpost(.end, log: osPOILog, name: "5.5 migration")
DDLogNotice("[AppMigration] App migration to version 5.5 successfully finished")
}
/// Migrate to version 5.6:
/// - Remove own contact from block list
private func migrateTo5_6() throws {
DDLogNotice("[AppMigration] App migration to version 5.6 started")
os_signpost(.begin, log: osPOILog, name: "5.6 migration")
if let blockList = businessInjector.userSettings.blacklist,
let myIdentity = businessInjector.myIdentityStore.identity,
blockList.contains(myIdentity) {
let mutableBlocklist = NSMutableOrderedSet(orderedSet: blockList)
mutableBlocklist.remove(myIdentity)
businessInjector.userSettings.blacklist = mutableBlocklist
DDLogNotice("[AppMigration] Removed own contact from block list")
}
os_signpost(.end, log: osPOILog, name: "5.6 migration")
DDLogNotice("[AppMigration] App migration to version 5.6 successfully finished")
}
/// Migrate to version 5.7:
/// - Migrate push settings dictionary to new type struct `PushSetting`
private func migrateTo5_7() throws {
DDLogNotice("[AppMigration] App migration to version 5.7 started")
os_signpost(.begin, log: osPOILog, name: "5.7 migration")
var newPushSettings = [PushSetting]()
let applyPushSetting: (NSDictionary, PushSetting) -> PushSetting = { dic, pushSetting -> PushSetting in
var pushSettingChanged = pushSetting
if let value = dic["type"] as? Int,
let type = PushSetting.PushSettingType(rawValue: value) {
pushSettingChanged.type = type
}
if let value = dic["periodOffTillDate"] as? Date {
pushSettingChanged.periodOffTillDate = value
}
if let value = dic["silent"] as? Bool {
pushSettingChanged.muted = value
}
if let value = dic["mentions"] as? Bool {
pushSettingChanged.mentioned = value
}
return pushSettingChanged
}
// Migrate `UserSettings.noPushIdentities` and `UserSettings.pushSettingsList`
let noPushIdentities = AppGroup.userDefaults().array(forKey: "NoPushIdentities") as? [String]
var pushSettingList = AppGroup.userDefaults().array(forKey: "PushSettingsList") ?? [Any]()
let pushSettingListIdentities = pushSettingList.compactMap { item in
if let dic = item as? NSDictionary,
let identity = dic["identity"] as? String {
return identity
}
return nil
}
noPushIdentities?.filter { item in
if item.count == 8 {
!(
pushSettingListIdentities.filter { $0.count == 8 }
.compactMap { $0.uppercased() }
.contains(item.uppercased())
)
}
else if let item = BytesUtility.toBytes(hexString: item) {
!(
pushSettingListIdentities.filter { $0.count > 8 }
.compactMap { BytesUtility.toBytes(hexString: $0) }
.contains(item)
)
}
else {
false
}
}.forEach { identity in
let dic = ["identity": identity, "type": 1] // `PushSettingType.off`
pushSettingList.append(dic)
}
if !pushSettingList.isEmpty {
for item in pushSettingList {
if let dic = item as? NSDictionary,
let identity = dic["identity"] as? String {
businessInjector.entityManager.performAndWait {
// The 'old' push setting identity, could be a Threema ID or a Group ID
var contactEntity: ContactEntity?
if identity.count == 8 {
contactEntity = self.businessInjector.entityManager.entityFetcher
.contactEntity(for: identity)
}
// Looking for contact or group for given push setting identity,
// if contact or group not found then push setting will discarded
if let contactEntity {
let pushSetting = applyPushSetting(
dic,
self.businessInjector.pushSettingManager.find(forContact: contactEntity.threemaIdentity)
)
newPushSettings.removeAll { item in
item.identity == pushSetting.identity
}
newPushSettings.append(pushSetting)
}
else if let myIdentity = self.businessInjector.myIdentityStore.identity,
let bytesGroupID = BytesUtility.toBytes(hexString: identity) {
let groupID = Data(bytesGroupID)
self.businessInjector.entityManager.performAndWait {
if let groupEntities = self.businessInjector.entityManager.entityFetcher
.groupEntities(for: groupID) {
for groupEntity in groupEntities {
if let group = self.businessInjector.groupManager.getGroup(
groupEntity.groupID,
creator: groupEntity.groupCreator ?? myIdentity
) {
let pushSetting = applyPushSetting(
dic,
self.businessInjector.pushSettingManager
.find(forGroup: group.groupIdentity)
)
newPushSettings.removeAll { item in
item.groupIdentity == pushSetting.groupIdentity
}
newPushSettings.append(pushSetting)
}
}
}
}
}
}
}
}
let jsonEncoder = JSONEncoder()
let newPushSettingsEncoded = try newPushSettings.map { item in
try jsonEncoder.encode(item)
}
businessInjector.userSettings.pushSettings = newPushSettingsEncoded
}
AppGroup.userDefaults().removeObject(forKey: "NoPushIdentities")
AppGroup.userDefaults().removeObject(forKey: "PushSettingsList")
// Old migration task, generate push setting for all groups, is not necessary anymore.
// Delete corresponding user setting
AppGroup.userDefaults().removeObject(forKey: "PushGroupGenerated")
os_signpost(.end, log: osPOILog, name: "5.7 migration")
DDLogNotice("[AppMigration] App migration to version 5.7 successfully finished")
}
/// Migrate to version 5.9:
/// - Migrate captions from json into the core data caption field
/// - Update the FS session data base to database version 5
/// - Remove unknown group alert list for pending group messages
private func migrateTo5_9() throws {
DDLogNotice("[AppMigration] App migration to version 5.9 started")
os_signpost(.begin, log: osPOILog, name: "5.9 migration")
businessInjector.entityManager.performAndWaitSave {
if let allFileMessages =
self.businessInjector.entityManager.entityFetcher
.fileMessagesWithJSONCaptionButEmptyCaption() {
for fileMessage in allFileMessages {
fileMessage.caption = fileMessage.jsonDescription
}
}
}
// Remove deprecated user settings
AppGroup.userDefaults().removeObject(forKey: "VideoCallInChatInfoShown")
AppGroup.userDefaults().removeObject(forKey: "VideoCallInfoShown")
AppGroup.userDefaults().removeObject(forKey: "VideoCallSpeakerInfoShown")
// Upgrade FS session data base to database version 5
try BusinessInjector.ui.dhSessionStore.executeNull()
// Remove unknown group alert list for pending group messages
AppGroup.userDefaults().removeObject(forKey: "UnknownGroupAlertList")
os_signpost(.end, log: osPOILog, name: "5.9 migration")
DDLogNotice("[AppMigration] App migration to version 5.9 successfully finished")
}
/// Migrate to version 5.9.2:
/// - Migrate from `PendingCreateID` to full `AppSetupState`
private func migrateTo5_9_2() throws {
DDLogNotice("[AppMigration] App migration to version 5.9.2 started")
os_signpost(.begin, log: osPOILog, name: "5.9.2 migration")
// Migrate from "PendingCreateID" to a full app setup state:
// If "PendingCreateID" is set the identity was created, but setup not completed
if AppGroup.userDefaults().bool(forKey: "PendingCreateID") {
AppSetup.state = .identityAdded
}
// If the identity is invalid or the app was deleted before we need to restart the setup
else if !AppSetup.hasPreexistingDatabaseFile || !MyIdentityStore.shared().isValidIdentity {
AppSetup.state = .notSetup
}
// By default the setup should be completed
else {
AppSetup.state = .complete
}
// Remove "PendingCreateID"
AppGroup.userDefaults().removeObject(forKey: "PendingCreateID")
os_signpost(.end, log: osPOILog, name: "5.9.2 migration")
DDLogNotice("[AppMigration] App migration to version 5.9.2 successfully finished")
}
/// Migrate to version 6.0:
/// - Set FileAudioMessages consumed date (1970) for existing messages
private func migrateTo6_0() throws {
DDLogNotice("[AppMigration] App migration to version 6.0 started")
os_signpost(.begin, log: osPOILog, name: "6.0 migration")
func decode(safeData data: Data) -> SafeData? {
try? NSKeyedUnarchiver.unarchivedObject(
ofClasses: [SafeData.self, NSArray.self, NSDate.self, NSNumber.self, NSString.self],
from: data
) as? SafeData
}
// Remember to setup of Threema Safe when downgrade Threema (only necessary if Threema Safe activated)
if let data = businessInjector.userSettings.safeConfig,
let safeData = decode(safeData: data),
safeData.key != nil {
businessInjector.userSettings.safeIntroShown = false
}
// Consumed date is new 1970 for all existing voice messages
businessInjector.entityManager.performAndWaitSave {
let batch = NSBatchUpdateRequest(entityName: "FileMessage")
batch.resultType = .statusOnlyResultType
batch.predicate = NSPredicate(
format: "isOwn == false AND type == 1 AND mimeType IN %@", UTIConverter.renderingAudioMimeTypes()
)
batch.propertiesToUpdate = ["consumed": Date(timeIntervalSince1970: 0)]
// If there was an error, the execute function will return nil or a result with the result 0
if let result = self.businessInjector.entityManager.entityFetcher.execute(batchUpdateRequest: batch) {
if let success = result.result as? Int,
success == 0 {
DDLogError(
"[AppMigration] Failed to set consumed date for existing voice messages"
)
}
}
else {
DDLogError(
"[AppMigration] Failed to set consumed date for existing voice messages"
)
}
}
os_signpost(.end, log: osPOILog, name: "6.0 migration")
DDLogNotice("[AppMigration] App migration to version 6.0 successfully finished")
}
/// Migrate to version 6.2:
/// - Remove lastWorkUpdateRequest in IdentityStore
private func migrateTo6_2() throws {
DDLogNotice("[AppMigration] App migration to version 6.2 started")
os_signpost(.begin, log: osPOILog, name: "6.2 migration")
/// - Remove lastWorkUpdateRequest in IdentityStore
AppGroup.userDefaults().removeObject(forKey: "LastWorkUpdateRequest")
AppGroup.userDefaults().synchronize()
os_signpost(.end, log: osPOILog, name: "6.2 migration")
DDLogNotice("[AppMigration] App migration to version 6.2 successfully finished")
}
/// Migrate to version 6.6:
/// - Migrate legacy reactions to new reactions
private func migrateTo6_6() throws {
DDLogNotice("[AppMigration] App migration to version 6.6 started")
os_signpost(.begin, log: osPOILog, name: "6.6 migration")
func addReaction(to message: BaseMessageEntity, from contact: ContactEntity?, thumbUp: Bool, at date: Date) {
let reaction = businessInjector.entityManager.entityCreator.messageReactionEntity(
reaction: thumbUp ? "" : "",
message: message
)
reaction.creator = contact
reaction.date = date
}
// Migrate existing ack/dec to new reactions
// 1:1 conversation messages
businessInjector.entityManager.performAndWaitSave {
guard let messages = self.businessInjector.entityManager.entityFetcher.messagesWithUserAckDate() else {
return
}
DDLogNotice(
"[AppMigration] App migration to version 6.6: found \(messages.count) from 1:1 chats with ack/dec."
)
for message in messages {
guard !message.willBeDeleted,
let userackDate = message.userackDate else {
continue
}
if message.isOwnMessage {
guard !message.conversation.willBeDeleted,
let contact = message.conversation.contact,
!contact.willBeDeleted else {
continue
}
addReaction(to: message, from: contact, thumbUp: message.userack.boolValue, at: userackDate)
}
else {
addReaction(to: message, from: nil, thumbUp: message.userack.boolValue, at: userackDate)
}
// Purge old ack/dec data
message.userackDate = nil
message.userack = 0
}
}
// Group messages
businessInjector.entityManager.performAndWaitSave {
guard let messages = self.businessInjector.entityManager.entityFetcher.messagesWithUserGroupReactions()
else {
return
}
DDLogNotice(
"[AppMigration] App migration to version 6.6: found \(messages.count) from group chats with ack/dec."
)
for message in messages {
guard let receipts = message.groupDeliveryReceipts, !receipts.isEmpty else {
continue
}
for receipt in receipts {
let type = receipt.deliveryReceiptType()
guard type == .acknowledged || type == .declined else {
continue
}
var contact: ContactEntity?
if receipt.identity != self.businessInjector.myIdentityStore.identity {
guard let fetchedContact = self.businessInjector.entityManager.entityFetcher
.contactEntity(for: receipt.identity) else {
continue
}
contact = fetchedContact
}
addReaction(to: message, from: contact, thumbUp: type == .acknowledged, at: receipt.date)
// Purge old ack/dec data
message.remove(groupDeliveryReceipt: receipt)
}
}
}
os_signpost(.end, log: osPOILog, name: "6.6 migration")
DDLogNotice("[AppMigration] App migration to version 6.6 successfully finished")
}
/// Migrate to version 6.8.8:
/// - Migrate profilePictureContactList if there are Threema ID's with "Optional"
private func migrateTo6_8_8() throws {
DDLogNotice("[AppMigration] App migration to version 6.8.8 started")
os_signpost(.begin, log: osPOILog, name: "6.8.8 migration")
var profilePictureContactList: [String] = []
var foundOptionalString = false
for identity in businessInjector.userSettings.profilePictureContactList as? [String] ?? [] {
if identity.starts(with: "Optional(") {
let startIndex = identity.index(identity.startIndex, offsetBy: 10)
let endIndex = identity.index(identity.endIndex, offsetBy: -3)
let substring = identity[startIndex...endIndex]
profilePictureContactList.append(String(substring))
foundOptionalString = true
}
else {
profilePictureContactList.append(identity)
}
}
if foundOptionalString {
businessInjector.userSettings.profilePictureContactList = profilePictureContactList
}
os_signpost(.end, log: osPOILog, name: "6.8.8 migration")
DDLogNotice("[AppMigration] App migration to version 6.8.8 successfully finished")
}
/// Migrate to version 6.9:
/// - Migrate some Settings to Keychain
private func migrateTo6_9() throws {
DDLogNotice("[AppMigration] App migration to version 6.9 started")
os_signpost(.begin, log: osPOILog, name: "6.9 migration")
try businessInjector.keychainManager.migrateToVersion1(
myIdentity: ThreemaIdentity(businessInjector.myIdentityStore.identity)
)
/// Migrate license
/// Before 6.9, we stored it in UserDefaults.
/// Now we need to migrate it to Keychain.
let persistenceKeyLicenseUser = "Threema license username"
let persistenceKeyLicensePassword = "Threema license password"
let persistenceKeyDeviceID = "Threema device ID"
let persistenceKeyOnPremConfigURL = "Threema OnPrem config URL"
if let user = AppGroup.userDefaults().string(forKey: persistenceKeyLicenseUser),
let password = AppGroup.userDefaults().string(forKey: persistenceKeyLicensePassword) {
let deviceID = AppGroup.userDefaults().string(forKey: persistenceKeyDeviceID)
let server = AppGroup.userDefaults().string(forKey: persistenceKeyOnPremConfigURL)
let license = ThreemaLicense(
user: user,
password: password,
deviceID: deviceID,
onPremServer: server
)
/// Due to it never being encrypted before, this is going to be
/// unencrypted.
/// Remote Secret was only introduced in 6.9, and if the customer
/// enables it, all the app data is going to be cleared and
/// the user needs to go over the onboarding again.
try businessInjector.keychainManager.storeLicense(license)
}
AppGroup.userDefaults().removeObject(forKey: persistenceKeyLicenseUser)
AppGroup.userDefaults().removeObject(forKey: persistenceKeyLicensePassword)
AppGroup.userDefaults().removeObject(forKey: persistenceKeyOnPremConfigURL)
AppGroup.userDefaults().removeObject(forKey: persistenceKeyDeviceID)
AppGroup.userDefaults().synchronize()
// Migrate device ID for multi device
if let deviceID = AppGroup.userDefaults().data(forKey: "DeviceID") {
try businessInjector.keychainManager.storeMultiDeviceID(id: deviceID)
AppGroup.userDefaults().removeObject(forKey: "DeviceID")
}
os_signpost(.end, log: osPOILog, name: "6.9 migration")
DDLogNotice("[AppMigration] App migration to version 6.9 successfully finished")
}
/// Migrate to version 7.0.4
/// - Migrate MDM to app group user defaults
private func migrateTo7_0_4() throws {
DDLogNotice("[AppMigration] App migration to version 7.0.4 started")
os_signpost(.begin, log: osPOILog, name: "7.0.4 migration")
guard let appGroupUserDefaults = AppGroup.userDefaults() else {
return
}
let defaults = UserDefaults.standard
// Threema MDM
let threemaMDMKey = "threema_mdm_configuration"
if let threemaMDM = defaults.dictionary(forKey: threemaMDMKey) {
appGroupUserDefaults.set(threemaMDM, forKey: threemaMDMKey)
defaults.removeObject(forKey: threemaMDMKey)
}
os_signpost(.end, log: osPOILog, name: "7.0.4 migration")
DDLogNotice("[AppMigration] App migration to version 7.0.4 successfully finished")
}
private func migrateTo7_1() throws {
DDLogNotice("[AppMigration] App migration to version 7.1 started")
os_signpost(.begin, log: osPOILog, name: "7.1 migration")
func migrateUserInterfaceStyleStoringKey() {
guard let defaults = AppGroup.userDefaults() else {
return
}
let darkThemeKey = "DarkTheme"
let useSystemThemeKey = "UseSystemTheme"
// Only migrate if the old values still exist.
guard
defaults.object(forKey: darkThemeKey) != nil,
defaults.object(forKey: useSystemThemeKey) != nil
else {
DDLogError(
"[AppMigration] \(darkThemeKey) and/or \(useSystemThemeKey) is missing. Skip user interface style migration."
)
return
}
let darkTheme = defaults.bool(forKey: darkThemeKey)
let useSystemTheme = defaults.bool(forKey: useSystemThemeKey)
let preferenceInterfaceStyle: Int =
if useSystemTheme {
UIUserInterfaceStyle.unspecified.rawValue
}
else if darkTheme {
UIUserInterfaceStyle.dark.rawValue
}
else {
UIUserInterfaceStyle.light.rawValue
}
// Update user defaults and in memory property
businessInjector.userSettings.interfaceStyle = preferenceInterfaceStyle
// Remove values stored in the old keys
defaults.removeObject(forKey: darkThemeKey)
defaults.removeObject(forKey: useSystemThemeKey)
defaults.synchronize()
Colors.resolveTheme()
}
migrateUserInterfaceStyleStoringKey()
os_signpost(.end, log: osPOILog, name: "7.1 migration")
DDLogNotice("[AppMigration] App migration to version 7.1 successfully finished")
}
/// Migrate to version 7.3:
/// Checks if data is available for file message types and sets a bool on them for efficient checking
private func migrateTo7_3() throws {
DDLogNotice("[AppMigration] App migration to version 7.3 started")
os_signpost(.begin, log: osPOILog, name: "7.3 migration")
// Shared info
let remoteSecretEnabled = RemoteSecretProvider.isRemoteSecretEnabled
try persistenceMigration.migrateDataAvailable(remoteSecretEnabled: remoteSecretEnabled)
// Removing legacy log files
for filename in [
"debug_log.txt",
"validation_log.txt",
"db-migration.log",
] {
if let utility = FileUtility.shared,
let url = utility.appDataDirectory(appGroupID: AppGroup.groupID())?.appendingPathComponent(filename),
utility.fileExists(at: url) {
try? utility.delete(at: url)
}
}
os_signpost(.end, log: osPOILog, name: "7.3 migration")
DDLogNotice("[AppMigration] App migration to version 7.3 successfully finished")
}
/// Migrate to version 7.4
/// - Delete document inbox if there is any
private func migrateTo7_4() throws {
DDLogNotice("[AppMigration] App migration to version 7.4 started")
os_signpost(.begin, log: osPOILog, name: "7.4 migration")
if let fileUtility = FileUtility.shared,
let documentInboxURL = fileUtility.appDocumentsDirectory?.appending(
path: "Inbox",
directoryHint: .isDirectory
) {
fileUtility.deleteIfExists(at: documentInboxURL)
}
else {
let dict = [
NSLocalizedDescriptionKey: "\(#localize("app_migration_uncompleted")). Unable to delete document inbox files",
]
let nsError = NSError(domain: "\(type(of: self))", code: 1, userInfo: dict)
throw nsError
}
os_signpost(.end, log: osPOILog, name: "7.4 migration")
DDLogNotice("[AppMigration] App migration to version 7.4 successfully finished")
}
/// Migrate to version 7.5
/// - Move the push notification encryption key from the user defaults into the Keychain
private func migrateTo7_5() throws {
DDLogNotice("[AppMigration] App migration to version 7.5 started")
os_signpost(.begin, log: osPOILog, name: "7.5 migration")
/// Move the push notification encryption key from the app group user defaults into the Keychain
///
/// Before 7.5 the key was stored in the user defaults, thus it was part of unencrypted backups. If it cannot be
/// moved the key is lost, but a new one is created on the next launch (see `AppLaunchTasks`) and registered
/// with
/// the server on the next login.
func migratePushNotificationEncryptionKeyToKeychain() {
guard let userDefaults = AppGroup.userDefaults(),
let key = userDefaults
.object(forKey: AppMigration.legacyPushEncryptionKeyUserDefaultsKey) as? Data else {
return
}
do {
try KeychainManager.storePushNotificationEncryptionKey(key)
}
catch {
DDLogError("[AppMigration] Unable to move the push notification encryption key: \(error)")
return
}
userDefaults.removeObject(forKey: AppMigration.legacyPushEncryptionKeyUserDefaultsKey)
DDLogNotice("[AppMigration] Moved the push notification encryption key into the Keychain")
}
migratePushNotificationEncryptionKeyToKeychain()
businessInjector.userSettings.ipcCommunicationEnabled = true
os_signpost(.end, log: osPOILog, name: "7.5 migration")
DDLogNotice("[AppMigration] App migration to version 7.5 successfully finished")
}
}
¤ Dauer der Verarbeitung: 0.7 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|