diff --git a/Mail/AppDelegate.swift b/Mail/AppDelegate.swift index 6335c3e883..eb19f6b366 100644 --- a/Mail/AppDelegate.swift +++ b/Mail/AppDelegate.swift @@ -42,13 +42,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { @InjectService var notificationService: InfomaniakNotifications + @InjectService var tokenStore: TokenStore + for account in AccountManager.instance.accounts { - guard let token = account.token else { continue } - let userApiFetcher = AccountManager.instance.getApiFetcher(for: token.userId, token: token) Task { /* Because of a backend issue we can't register the notification token directly after the creation or refresh of an API token. We wait at least 15 seconds before trying to register. */ try? await Task.sleep(nanoseconds: 15_000_000_000) + + guard let token = tokenStore.tokenFor(userId: account.userId) else { return } + let userApiFetcher = AccountManager.instance.getApiFetcher(for: token.userId, token: token) await notificationService.updateRemoteNotificationsToken(tokenData: deviceToken, userApiFetcher: userApiFetcher, updatePolicy: .always) diff --git a/Mail/MailApp.swift b/Mail/MailApp.swift index 7ecbfdbb49..ca32cc5d82 100644 --- a/Mail/MailApp.swift +++ b/Mail/MailApp.swift @@ -45,6 +45,9 @@ public struct EarlyDIHook { let keychainHelper = Factory(type: KeychainHelper.self) { _, _ in KeychainHelper(accessGroup: AccountManager.accessGroup) } + let tokenStore = Factory(type: TokenStore.self) { _, _ in + TokenStore() + } let notificationService = Factory(type: InfomaniakNotifications.self) { _, _ in InfomaniakNotifications(appGroup: AccountManager.appGroup) } @@ -71,6 +74,7 @@ public struct EarlyDIHook { SimpleResolver.sharedResolver.store(factory: loginService) SimpleResolver.sharedResolver.store(factory: notificationService) SimpleResolver.sharedResolver.store(factory: keychainHelper) + SimpleResolver.sharedResolver.store(factory: tokenStore) SimpleResolver.sharedResolver.store(factory: appLockHelper) SimpleResolver.sharedResolver.store(factory: bugTracker) SimpleResolver.sharedResolver.store(factory: matomoUtils) diff --git a/Mail/Views/Alerts/LogoutConfirmationView.swift b/Mail/Views/Alerts/LogoutConfirmationView.swift index b41c765c26..fabceb5f18 100644 --- a/Mail/Views/Alerts/LogoutConfirmationView.swift +++ b/Mail/Views/Alerts/LogoutConfirmationView.swift @@ -43,12 +43,13 @@ struct LogoutConfirmationView: View { Task { @InjectService var notificationService: InfomaniakNotifications await notificationService.removeStoredTokenFor(userId: account.userId) + + AccountManager.instance.removeTokenAndAccount(account: account) + if let nextAccount = AccountManager.instance.accounts.first { + AccountManager.instance.switchAccount(newAccount: nextAccount) + } + AccountManager.instance.saveAccounts() } - AccountManager.instance.removeTokenAndAccount(token: account.token) - if let nextAccount = AccountManager.instance.accounts.first { - AccountManager.instance.switchAccount(newAccount: nextAccount) - } - AccountManager.instance.saveAccounts() } } diff --git a/Mail/Views/Switch User/AccountView.swift b/Mail/Views/Switch User/AccountView.swift index 6d721074e3..6157f0cd1c 100644 --- a/Mail/Views/Switch User/AccountView.swift +++ b/Mail/Views/Switch User/AccountView.swift @@ -27,13 +27,15 @@ import SwiftUI class AccountViewDelegate: DeleteAccountDelegate { @MainActor func didCompleteDeleteAccount() { - guard let account = AccountManager.instance.getCurrentAccount() else { return } - AccountManager.instance.removeTokenAndAccount(token: account.token) - if let nextAccount = AccountManager.instance.accounts.first { - AccountManager.instance.switchAccount(newAccount: nextAccount) - IKSnackBar.showSnackBar(message: "Account deleted") + Task { + guard let account = AccountManager.instance.getCurrentAccount() else { return } + AccountManager.instance.removeTokenAndAccount(account: account) + if let nextAccount = AccountManager.instance.accounts.first { + AccountManager.instance.switchAccount(newAccount: nextAccount) + IKSnackBar.showSnackBar(message: "Account deleted") + } + AccountManager.instance.saveAccounts() } - AccountManager.instance.saveAccounts() } @MainActor func didFailDeleteAccount(error: InfomaniakLoginError) { @@ -43,15 +45,16 @@ class AccountViewDelegate: DeleteAccountDelegate { } struct AccountView: View { + @LazyInjectService private var matomo: MatomoUtils + @LazyInjectService private var tokenStore: TokenStore + @Environment(\.dismiss) private var dismiss @EnvironmentObject private var mailboxManager: MailboxManager @AppStorage(UserDefaults.shared.key(.accentColor)) private var accentColor = DefaultPreferences.accentColor - @LazyInjectService private var matomo: MatomoUtils - @State private var isShowingLogoutAlert = false - @State private var isShowingDeleteAccount = false + @State private var presentedDeletedToken: ApiToken? @State private var delegate = AccountViewDelegate() let account: Account @@ -94,7 +97,7 @@ struct AccountView: View { .padding(.bottom, 24) MailButton(label: MailResourcesStrings.Localizable.buttonAccountDelete) { matomo.track(eventWithCategory: .account, name: "deleteAccount") - isShowingDeleteAccount.toggle() + presentedDeletedToken = tokenStore.removeTokenFor(account: account) } .mailButtonStyle(.destructive) .padding(.bottom, 24) @@ -102,8 +105,8 @@ struct AccountView: View { .padding(.horizontal, 16) .navigationBarTitle(MailResourcesStrings.Localizable.titleMyAccount, displayMode: .inline) .backButtonDisplayMode(.minimal) - .sheet(isPresented: $isShowingDeleteAccount) { - DeleteAccountView(account: account, delegate: delegate) + .sheet(item: $presentedDeletedToken) { deletedToken in + DeleteAccountView(token: deletedToken, delegate: delegate) } .customAlert(isPresented: $isShowingLogoutAlert) { LogoutConfirmationView(account: account) @@ -113,6 +116,12 @@ struct AccountView: View { } } +extension ApiToken: Identifiable { + public var id: Int { + return userId + } +} + struct AccountView_Previews: PreviewProvider { static var previews: some View { AccountView(account: PreviewHelper.sampleAccount) diff --git a/Mail/Views/Switch User/DeleteAccountView.swift b/Mail/Views/Switch User/DeleteAccountView.swift index 85d41a2f95..0ceb0972bf 100644 --- a/Mail/Views/Switch User/DeleteAccountView.swift +++ b/Mail/Views/Switch User/DeleteAccountView.swift @@ -22,13 +22,13 @@ import MailCore import SwiftUI struct DeleteAccountView: UIViewControllerRepresentable { - var account: Account - var delegate: DeleteAccountDelegate + let token: ApiToken + let delegate: DeleteAccountDelegate func makeUIViewController(context: Context) -> UINavigationController { return DeleteAccountViewController.instantiateInViewController( delegate: delegate, - accessToken: account.token.accessToken, + accessToken: token.accessToken, navBarColor: nil, navBarButtonColor: nil ) diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift index 2117e03e75..84e9a7a919 100644 --- a/MailCore/API/MailApiFetcher.swift +++ b/MailCore/API/MailApiFetcher.swift @@ -382,6 +382,7 @@ class SyncedAuthenticator: OAuthAuthenticator { ) { AccountManager.instance.refreshTokenLockedQueue.async { @InjectService var keychainHelper: KeychainHelper + @InjectService var tokenStore: TokenStore @InjectService var networkLoginService: InfomaniakNetworkLoginable SentrySDK @@ -395,8 +396,7 @@ class SyncedAuthenticator: OAuthAuthenticator { } // Maybe someone else refreshed our token - AccountManager.instance.reloadTokensAndAccounts() - if let token = AccountManager.instance.getTokenForUserId(credential.userId), + if let token = tokenStore.tokenFor(userId: credential.userId, fetchLocation: .keychain), token.expirationDate > credential.expirationDate { SentrySDK .addBreadcrumb(token.generateBreadcrumb(level: .info, message: "Refreshing token - Success with local")) diff --git a/MailCore/Cache/AccountManager.swift b/MailCore/Cache/AccountManager.swift index ff1d8c58c5..c0be7ab349 100644 --- a/MailCore/Cache/AccountManager.swift +++ b/MailCore/Cache/AccountManager.swift @@ -59,17 +59,9 @@ public extension InfomaniakNetworkLoginable { } } -@globalActor actor AccountActor: GlobalActor { - static let shared = AccountActor() - - public static func run(resultType: T.Type = T.self, body: @AccountActor @Sendable () throws -> T) async rethrows -> T { - try await body() - } -} - public class AccountManager: RefreshTokenDelegate, ObservableObject { @LazyInjectService var networkLoginService: InfomaniakNetworkLoginable - @LazyInjectService var keychainHelper: KeychainHelper + @LazyInjectService var tokenStore: TokenStore @LazyInjectService var bugTracker: BugTracker @LazyInjectService var notificationService: InfomaniakNotifications @LazyInjectService var matomo: MatomoUtils @@ -82,7 +74,6 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { private var currentAccount: Account? public var accounts = SendableArray() - public var tokens = SendableArray() public let refreshTokenLockedQueue = DispatchQueue(label: "com.infomaniak.mail.refreshtoken") public weak var delegate: AccountManagerDelegate? public var currentUserId: Int { @@ -149,11 +140,6 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { let newAccounts = loadAccounts() accounts.append(contentsOf: newAccounts) - if !accounts.isEmpty { - tokens.removeAll() - tokens.append(contentsOf: keychainHelper.loadTokens()) - } - // Also update current account reference to prevent mismatch if let account = accounts.values.first(where: { $0.userId == currentAccount?.userId }) { currentAccount = account @@ -163,15 +149,6 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { for account in accounts where account.user == nil { removeAccount(toDeleteAccount: account) } - - for token in tokens { - if let account = account(for: token.userId) { - account.token = token - } else { - // remove token with no account - removeTokenAndAccount(token: token) - } - } } public func getMailboxManager(for mailbox: Mailbox) -> MailboxManager? { @@ -184,7 +161,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { if let mailboxManager = mailboxManagers[objectId] { return mailboxManager } else if let account = account(for: userId), - let token = account.token, + let token = tokenStore.tokenFor(userId: userId), let mailbox = MailboxInfosManager.instance.getMailbox(id: mailboxId, userId: userId) { let apiFetcher = getApiFetcher(for: userId, token: token) let contactManager = getContactManager(for: userId, apiFetcher: apiFetcher) @@ -218,12 +195,8 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { } } - public func getTokenForUserId(_ id: Int) -> ApiToken? { - return account(for: id)?.token - } - public func didUpdateToken(newToken: ApiToken, oldToken: ApiToken) { - updateToken(newToken: newToken, oldToken: oldToken) + tokenStore.addToken(newToken: newToken) } public func didFailRefreshToken(_ token: ApiToken) { @@ -233,14 +206,11 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { key: "Token Infos" ) } - tokens.removeAll { $0.userId == token.userId } - keychainHelper.deleteToken(for: token.userId) - if let account = account(for: token.userId) { - account.token = nil - if account.userId == currentUserId { - delegate?.currentAccountNeedsAuthentication() - NotificationsHelper.sendDisconnectedNotification() - } + tokenStore.removeTokenFor(userId: token.userId) + if let account = account(for: token.userId), + account.userId == currentUserId { + delegate?.currentAccountNeedsAuthentication() + NotificationsHelper.sendDisconnectedNotification() } } @@ -265,7 +235,7 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { let newAccount = Account(apiToken: token) newAccount.user = user - addAccount(account: newAccount) + addAccount(account: newAccount, token: token) setCurrentAccount(account: newAccount) for mailbox in mailboxesResponse { @@ -290,13 +260,12 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { } public func updateUser(for account: Account?) async throws { - guard let account, account.isConnected else { + guard let account, + let token = tokenStore.tokenFor(userId: account.userId) else { throw MailError.noToken } - let apiFetcher = await AccountActor.run { - getApiFetcher(for: account.userId, token: account.token) - } + let apiFetcher = getApiFetcher(for: account.userId, token: token) let user = try await apiFetcher.userProfile(dateFormat: .iso8601) account.user = user @@ -457,12 +426,12 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { _ = getMailboxManager(for: mailbox) } - public func addAccount(account: Account) { + public func addAccount(account: Account, token: ApiToken) { if accounts.values.contains(account) { removeAccount(toDeleteAccount: account) } accounts.append(account) - keychainHelper.storeToken(account.token) + tokenStore.addToken(newToken: token) saveAccounts() } @@ -481,13 +450,12 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { accounts.removeAll { $0 == toDeleteAccount } } - public func removeTokenAndAccount(token: ApiToken) { - tokens.removeAll { $0.userId == token.userId } - keychainHelper.deleteToken(for: token.userId) - if let account = account(for: token) { - removeAccount(toDeleteAccount: account) - } - networkLoginService.deleteApiToken(token: token) { error in + public func removeTokenAndAccount(account: Account) { + let removedToken = tokenStore.removeTokenFor(userId: account.userId) + removeAccount(toDeleteAccount: account) + + guard let removedToken else { return } + networkLoginService.deleteApiToken(token: removedToken) { error in DDLogError("Failed to delete api token: \(error.localizedDescription)") } } @@ -500,19 +468,12 @@ public class AccountManager: RefreshTokenDelegate, ObservableObject { return accounts.values.first { $0.userId == userId } } - public func updateToken(newToken: ApiToken, oldToken: ApiToken) { - keychainHelper.storeToken(newToken) - for account in accounts where oldToken.userId == account.userId { - account.token = newToken - } - tokens.removeAll { $0.userId == oldToken.userId } - tokens.append(newToken) - } - public func enableBugTrackerIfAvailable() { - if let currentAccount, currentAccount.user?.isStaff == true { + if let currentAccount, + let token = tokenStore.tokenFor(userId: currentAccount.userId), + currentAccount.user?.isStaff == true { bugTracker.activateOnScreenshot() - let apiFetcher = getApiFetcher(for: currentAccount.userId, token: currentAccount.token) + let apiFetcher = getApiFetcher(for: currentAccount.userId, token: token) bugTracker.configure(with: apiFetcher) } else { bugTracker.stopActivatingOnScreenshot() diff --git a/MailCore/Cache/TokenStore.swift b/MailCore/Cache/TokenStore.swift new file mode 100644 index 0000000000..009c153d9d --- /dev/null +++ b/MailCore/Cache/TokenStore.swift @@ -0,0 +1,68 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation +import InfomaniakCore +import InfomaniakDI + +public class TokenStore { + public enum TokenStoreFetchLocation { + case cache + case keychain + } + + public typealias UserId = Int + @LazyInjectService private var keychainHelper: KeychainHelper + private let tokens = SendableDictionary() + + public init() { + let keychainTokens = keychainHelper.loadTokens() + for token in keychainTokens { + tokens[token.userId] = token + } + } + + @discardableResult + public func removeTokenFor(userId: UserId) -> ApiToken? { + let removedToken = tokens.removeValue(forKey: userId) + keychainHelper.deleteToken(for: userId) + + return removedToken + } + + @discardableResult + public func removeTokenFor(account: Account) -> ApiToken? { + return removeTokenFor(userId: account.userId) + } + + public func tokenFor(userId: UserId, fetchLocation: TokenStoreFetchLocation = .cache) -> ApiToken? { + if fetchLocation == .keychain { + let keychainTokens = keychainHelper.loadTokens() + for token in keychainTokens { + tokens[token.userId] = token + } + } + + return tokens[userId] + } + + public func addToken(newToken: ApiToken) { + keychainHelper.storeToken(newToken) + tokens[newToken.userId] = newToken + } +} diff --git a/MailNotificationServiceExtension/NotificationService.swift b/MailNotificationServiceExtension/NotificationService.swift index b80bed7094..2bd3ba7744 100644 --- a/MailNotificationServiceExtension/NotificationService.swift +++ b/MailNotificationServiceExtension/NotificationService.swift @@ -41,6 +41,9 @@ class NotificationService: UNNotificationServiceExtension { let keychainHelper = Factory(type: KeychainHelper.self) { _, _ in KeychainHelper(accessGroup: AccountManager.accessGroup) } + let tokenStore = Factory(type: TokenStore.self) { _, _ in + TokenStore() + } let notificationService = Factory(type: InfomaniakNotifications.self) { _, _ in InfomaniakNotifications(appGroup: AccountManager.appGroup) } @@ -48,6 +51,7 @@ class NotificationService: UNNotificationServiceExtension { SimpleResolver.sharedResolver.store(factory: networkLoginService) SimpleResolver.sharedResolver.store(factory: loginService) SimpleResolver.sharedResolver.store(factory: keychainHelper) + SimpleResolver.sharedResolver.store(factory: tokenStore) SimpleResolver.sharedResolver.store(factory: notificationService) }