Switch to stable, hash-based account IDs

#160
This commit is contained in:
Shadowfacts 2022-09-12 23:05:35 -04:00
parent bbfb3b0a7a
commit 35d21fb725
3 changed files with 92 additions and 25 deletions

View File

@ -8,6 +8,7 @@
import Foundation
import Combine
import CryptoKit
class LocalData: ObservableObject {
@ -22,7 +23,6 @@ class LocalData: ObservableObject {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [
UserAccountInfo(
id: UUID().uuidString,
instanceURL: URL(string: "http://localhost:8080")!,
clientID: "client_id",
clientSecret: "client_secret",
@ -33,23 +33,15 @@ class LocalData: ObservableObject {
} else {
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
}
migrateAccountIDsIfNecessary()
}
private let accountsKey = "accounts"
var accounts: [UserAccountInfo] {
private(set) var accounts: [UserAccountInfo] {
get {
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
return array.compactMap { (info) in
guard let id = info["id"],
let instanceURL = info["instanceURL"],
let url = URL(string: instanceURL),
let clientId = info["clientID"],
let secret = info["clientSecret"],
let accessToken = info["accessToken"] else {
return nil
}
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken)
}
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
} else {
return []
}
@ -84,6 +76,41 @@ class LocalData: ObservableObject {
}
}
private let usesAccountIDHashesKey = "usesAccountIDHashes"
private var usesAccountIDHashes: Bool {
get {
return defaults.bool(forKey: usesAccountIDHashesKey)
}
set {
return defaults.set(newValue, forKey: usesAccountIDHashesKey)
}
}
private func migrateAccountIDsIfNecessary() {
if usesAccountIDHashes {
return
}
if let mostRecentAccount = getMostRecentAccount() {
let hashedMostRecentID = UserAccountInfo.id(instanceURL: mostRecentAccount.instanceURL, username: mostRecentAccount.username)
mostRecentAccountID = hashedMostRecentID
}
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
accounts = array.compactMap {
guard let urlString = $0["instanceURL"],
let url = URL(string: urlString),
let username = $0["username"] else {
return nil
}
var dict = $0
dict["id"] = UserAccountInfo.id(instanceURL: url, username: username)
return UserAccountInfo(userDefaultsDict: dict)
}
}
usesAccountIDHashes = true
}
// MARK: - Account Management
var onboardingComplete: Bool {
return !accounts.isEmpty
}
@ -93,20 +120,12 @@ class LocalData: ObservableObject {
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
}
let id = UUID().uuidString
let info = UserAccountInfo(id: id, instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
accounts.append(info)
self.accounts = accounts
return info
}
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id })
}
@ -138,9 +157,57 @@ extension LocalData {
let instanceURL: URL
let clientID: String
let clientSecret: String
fileprivate(set) var username: String!
private(set) var username: String!
let accessToken: String
fileprivate static let tempAccountID = "temp"
fileprivate static func id(instanceURL: URL, username: String?) -> String {
// We hash the instance host and username to form the account ID
// so that account IDs will match across devices, allowing for data syncing and handoff.
var hasher = SHA256()
hasher.update(data: instanceURL.host!.data(using: .utf8)!)
if let username {
hasher.update(data: username.data(using: .utf8)!)
}
return Data(hasher.finalize()).base64EncodedString()
}
/// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username
init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
self.id = UserAccountInfo.tempAccountID
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.accessToken = accessToken
}
fileprivate init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.username = username
self.accessToken = accessToken
}
fileprivate init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"],
let instanceURL = dict["instanceURL"],
let url = URL(string: instanceURL),
let clientID = dict["clientID"],
let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] else {
return nil
}
self.id = id
self.instanceURL = url
self.clientID = clientID
self.clientSecret = secret
self.username = dict["username"]
self.accessToken = accessToken
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}

View File

@ -62,7 +62,7 @@ class OnboardingViewController: UINavigationController {
}
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
let ownAccount: Account

View File

@ -48,7 +48,7 @@ struct PreferencesView: View {
indices.remove(index)
}
localData.accounts.remove(atOffsets: indices)
indices.forEach { localData.removeAccount(localData.accounts[$0]) }
if logoutFromCurrent {
self.logoutPressed()