Files
letro-ios/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift
2026-04-08 21:18:03 +01:00

144 lines
6.3 KiB
Swift

//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
class ClassicAppAccountManager {
private let cacheFolder: URL
private let aesKey: Data
private let iv: Data
private let cryptoStorePassphrase: Data
private(set) var accounts: [ClassicAppAccount] = []
init(cacheFolder: URL, aesKey: Data, iv: Data, cryptoStorePassphrase: Data) {
self.cacheFolder = cacheFolder
self.aesKey = aesKey
self.iv = iv
self.cryptoStorePassphrase = cryptoStorePassphrase
}
func loadAccounts() {
MXLog.info("Loading accounts from Classic app.")
// First we need to load the accounts file, which contains an array of MXKAccounts (a super class of MXAccount).
let accountFile = accountFile()
if FileManager.default.fileExists(atPath: accountFile.path(percentEncoded: false)) {
let startDate = Date()
do {
let fileContent = try Data(contentsOf: accountFile, options: [.alwaysMapped, .uncached])
let unciphered = try ClassicAppAES.decrypt(fileContent, aesKey: aesKey, iv: iv)
let decoder = try NSKeyedUnarchiver(forReadingFrom: unciphered)
decoder.requiresSecureCoding = false
decoder.setClass(ClassicAppMXAccount.self, forClassName: "MXKAccount")
guard let mxAccounts = decoder.decodeObject(forKey: "mxAccounts") as? [ClassicAppMXAccount] else {
MXLog.error("Failed to decode accounts.")
return
}
MXLog.info("\(mxAccounts.count) accounts loaded in \(Date().timeIntervalSince(startDate) * 1000)ms.")
// Only consider active accounts using the same logic as Element Classic and then
// combine the MXAccount data with its profile and crypto store details.
accounts = mxAccounts
.filter(\.isActive)
.compactMap(makeAccount)
MXLog.info("\(mxAccounts.count) active accounts available.")
} catch {
MXLog.error("Failed to load account file: \(error)")
}
}
}
// MARK: - Private
/// Combines an MXAccount with its profile and crypto store details.
private func makeAccount(for mxAccount: ClassicAppMXAccount) -> ClassicAppAccount? {
let userID = mxAccount.userID
let user = loadUser(for: mxAccount) // We need an MXUser for the profile as MXAccount doesn't contain that data.
guard let serverName = serverName(for: userID) else { return nil }
return ClassicAppAccount(userID: userID,
displayName: user?.displayName,
avatarURL: user?.avatarURL,
serverName: serverName,
homeserverURL: mxAccount.homeserverURL,
cryptoStoreURL: cryptoStoreURL(for: userID),
cryptoStorePassphrase: cryptoStorePassphrase.base64EncodedString(),
accessToken: mxAccount.accessToken)
}
private func loadUser(for mxAccount: ClassicAppMXAccount) -> ClassicAppMXUser? {
// Users are stored across multiple files, so first find the right file for this particular user.
let userID = mxAccount.userID
let groupID = String(UInt(bitPattern: userID.hash) % 100) // Swift's .hash is Int whereas Objective-C's is UInt.
let groupFile = storeUsersPath(for: userID).appending(component: groupID)
guard FileManager.default.fileExists(atPath: groupFile.path(percentEncoded: false)) else {
MXLog.warning("Missing users group \(groupID) file for \(mxAccount.userID).")
return nil
}
do {
// And then load that file and find the user within its data.
let fileContent = try Data(contentsOf: groupFile)
let decoder = try NSKeyedUnarchiver(forReadingFrom: fileContent)
decoder.requiresSecureCoding = false
decoder.setClass(ClassicAppMXUser.self, forClassName: "MXUser")
decoder.setClass(ClassicAppMXUser.self, forClassName: "MXMyUser")
let groupUsers = decoder.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? [String: ClassicAppMXUser]
return groupUsers?[userID]
} catch {
MXLog.warning("Users group \(groupID) file for \(mxAccount.userID) has been corrupted.")
return nil
}
}
/// The server name extracted from the user's ID.
private func serverName(for userID: String) -> String? {
let components = userID.components(separatedBy: ":")
guard components.count > 1 else { return nil }
return components[1] // Directly use [1] as .last may be the port number.
}
// MARK: - File URLs
private static let matrixKitFolder = "MatrixKit"
private static let cryptoStoreFolder = "MXCryptoStore"
private static let kMXKAccountsKey = "accountsV2"
private static let kMXFileStoreFolder = "MXFileStore"
private static let kMXFileStoreUsersFolder = "users"
/// The file URL that contains the app's `MXAccounts` array.
func accountFile() -> URL {
cacheFolder.appending(component: Self.matrixKitFolder).appending(component: Self.kMXKAccountsKey)
}
/// The database file URL as defined in `MXCryptoMachineStore`.
func cryptoStoreURL(for userID: String) -> URL {
cacheFolder.appending(component: Self.cryptoStoreFolder).appending(component: userID)
}
/// This store contains all of the users known to the specific user ID.
func storeUsersPath(for userID: String) -> URL {
storePath(for: userID).appending(component: Self.kMXFileStoreUsersFolder)
}
private func storePath(for userID: String) -> URL {
fileStorePath.appending(component: userID)
}
private var fileStorePath: URL {
cacheFolder.appending(component: Self.kMXFileStoreFolder)
}
}