Add a recovery key screen hook. (#5684)

update generateRecoveryKey to take a passphrase option

Wire up AppHooks with recovery key hook

Update xcodeproj

Review fixups

Review fixups

Make the `SecureBackupRecoveryState.viewMode` extension private

Bump the enterprise submodule
This commit is contained in:
Michael
2026-06-18 07:28:57 -05:00
committed by GitHub
parent 73ea338d67
commit 7edca8b5a1
16 changed files with 97 additions and 39 deletions
+9
View File
@@ -68,6 +68,15 @@ final class AppHooks: AppHooksProtocol {
func registerDeveloperOptionsScreenHook(_ hook: DeveloperOptionsScreenHookProtocol) {
_developerOptionsScreenHook.withLock { $0 = hook }
}
private let _recoveryKeyScreenHook: Mutex<RecoveryKeyScreenHookProtocol> = Mutex(DefaultRecoveryKeyScreenHook())
var recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol {
_recoveryKeyScreenHook.withLock { $0 }
}
func registerRecoveryKeyScreenHook(_ hook: RecoveryKeyScreenHookProtocol) {
_recoveryKeyScreenHook.withLock { $0 = hook }
}
#endif
private let _tracingHook: Mutex<TracingHookProtocol> = Mutex(DefaultTracingHook())
@@ -0,0 +1,23 @@
//
// 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 Combine
import SwiftUI
@MainActor protocol SecureBackupRecoveryKeyCoordinatorProtocol: CoordinatorProtocol {
var actions: AnyPublisher<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> { get }
}
protocol RecoveryKeyScreenHookProtocol {
@MainActor func makeCoordinator(parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters, homeserver: String) -> any SecureBackupRecoveryKeyCoordinatorProtocol
}
struct DefaultRecoveryKeyScreenHook: RecoveryKeyScreenHookProtocol {
@MainActor func makeCoordinator(parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters, homeserver: String) -> any SecureBackupRecoveryKeyCoordinatorProtocol {
SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
}
}
@@ -672,7 +672,8 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: flowParameters.userIndicatorController,
isModallyPresented: true)
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
let coordinator = flowParameters.appHooks.recoveryKeyScreenHook.makeCoordinator(parameters: parameters,
homeserver: userSession.clientProxy.homeserver)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
@@ -18,6 +18,7 @@ enum EncryptionSettingsFlowCoordinatorAction: Equatable {
struct EncryptionSettingsFlowCoordinatorParameters {
let userSession: UserSessionProtocol
let appSettings: AppSettings
let appHooks: AppHooks
let userIndicatorController: UserIndicatorControllerProtocol
let navigationStackCoordinator: NavigationStackCoordinator
}
@@ -25,6 +26,7 @@ struct EncryptionSettingsFlowCoordinatorParameters {
class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol
private let appSettings: AppSettings
private let appHooks: AppHooks
private let userIndicatorController: UserIndicatorControllerProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
@@ -65,6 +67,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
init(parameters: EncryptionSettingsFlowCoordinatorParameters) {
userSession = parameters.userSession
appSettings = parameters.appSettings
appHooks = parameters.appHooks
userIndicatorController = parameters.userIndicatorController
navigationStackCoordinator = parameters.navigationStackCoordinator
@@ -158,9 +161,11 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
private func presentRecoveryKeyScreen() {
let sheetNavigationStackCoordinator = NavigationStackCoordinator()
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController,
userIndicatorController: userIndicatorController,
isModallyPresented: true))
let parameters = SecureBackupRecoveryKeyScreenCoordinatorParameters(secureBackupController: userSession.clientProxy.secureBackupController,
userIndicatorController: userIndicatorController,
isModallyPresented: true)
let coordinator = appHooks.recoveryKeyScreenHook.makeCoordinator(parameters: parameters,
homeserver: userSession.clientProxy.homeserver)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -302,7 +302,8 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: userIndicatorController,
isModallyPresented: false)
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
let coordinator = appHooks.recoveryKeyScreenHook.makeCoordinator(parameters: parameters,
homeserver: userSession.clientProxy.homeserver)
coordinator.actions
.sink { action in
@@ -143,6 +143,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private func startEncryptionSettingsFlow(animated: Bool) {
let coordinator = EncryptionSettingsFlowCoordinator(parameters: .init(userSession: flowParameters.userSession,
appSettings: flowParameters.appSettings,
appHooks: flowParameters.appHooks,
userIndicatorController: flowParameters.userIndicatorController,
navigationStackCoordinator: navigationStackCoordinator))
coordinator.actionsPublisher.sink { [weak self] action in
@@ -10963,30 +10963,44 @@ class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sen
}
//MARK: - generateRecoveryKey
private let generateRecoveryKeyCallsCountLock = NSLock()
private var generateRecoveryKeyUnderlyingCallsCount = 0
var generateRecoveryKeyCallsCount: Int {
get { generateRecoveryKeyCallsCountLock.withLock { generateRecoveryKeyUnderlyingCallsCount } }
set { generateRecoveryKeyCallsCountLock.withLock { generateRecoveryKeyUnderlyingCallsCount = newValue } }
private let generateRecoveryKeyWithPassphraseCallsCountLock = NSLock()
private var generateRecoveryKeyWithPassphraseUnderlyingCallsCount = 0
var generateRecoveryKeyWithPassphraseCallsCount: Int {
get { generateRecoveryKeyWithPassphraseCallsCountLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingCallsCount } }
set { generateRecoveryKeyWithPassphraseCallsCountLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingCallsCount = newValue } }
}
var generateRecoveryKeyCalled: Bool {
return generateRecoveryKeyCallsCount > 0
var generateRecoveryKeyWithPassphraseCalled: Bool {
return generateRecoveryKeyWithPassphraseCallsCount > 0
}
private let generateRecoveryKeyWithPassphraseReceivedPassphraseLock = NSLock()
private var generateRecoveryKeyWithPassphraseUnderlyingReceivedPassphrase: String?
var generateRecoveryKeyWithPassphraseReceivedPassphrase: String? {
get { generateRecoveryKeyWithPassphraseReceivedPassphraseLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReceivedPassphrase } }
set { generateRecoveryKeyWithPassphraseReceivedPassphraseLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReceivedPassphrase = newValue } }
}
private let generateRecoveryKeyWithPassphraseReceivedInvocationsLock = NSLock()
private var generateRecoveryKeyWithPassphraseUnderlyingReceivedInvocations: [String?] = []
var generateRecoveryKeyWithPassphraseReceivedInvocations: [String?] {
get { generateRecoveryKeyWithPassphraseReceivedInvocationsLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReceivedInvocations } }
set { generateRecoveryKeyWithPassphraseReceivedInvocationsLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReceivedInvocations = newValue } }
}
private let generateRecoveryKeyReturnValueLock = NSLock()
private var generateRecoveryKeyUnderlyingReturnValue: Result<String, SecureBackupControllerError>!
var generateRecoveryKeyReturnValue: Result<String, SecureBackupControllerError>! {
get { generateRecoveryKeyReturnValueLock.withLock { generateRecoveryKeyUnderlyingReturnValue } }
set { generateRecoveryKeyReturnValueLock.withLock { generateRecoveryKeyUnderlyingReturnValue = newValue } }
private let generateRecoveryKeyWithPassphraseReturnValueLock = NSLock()
private var generateRecoveryKeyWithPassphraseUnderlyingReturnValue: Result<String, SecureBackupControllerError>!
var generateRecoveryKeyWithPassphraseReturnValue: Result<String, SecureBackupControllerError>! {
get { generateRecoveryKeyWithPassphraseReturnValueLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReturnValue } }
set { generateRecoveryKeyWithPassphraseReturnValueLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReturnValue = newValue } }
}
var generateRecoveryKeyClosure: (() async -> Result<String, SecureBackupControllerError>)?
var generateRecoveryKeyWithPassphraseClosure: ((String?) async -> Result<String, SecureBackupControllerError>)?
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError> {
generateRecoveryKeyCallsCountLock.withLock { generateRecoveryKeyUnderlyingCallsCount += 1 }
if let generateRecoveryKeyClosure = generateRecoveryKeyClosure {
return await generateRecoveryKeyClosure()
func generateRecoveryKey(withPassphrase passphrase: String?) async -> Result<String, SecureBackupControllerError> {
generateRecoveryKeyWithPassphraseCallsCountLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingCallsCount += 1 }
generateRecoveryKeyWithPassphraseReceivedPassphrase = passphrase
generateRecoveryKeyWithPassphraseReceivedInvocationsLock.withLock { generateRecoveryKeyWithPassphraseUnderlyingReceivedInvocations.append(passphrase) }
if let generateRecoveryKeyWithPassphraseClosure = generateRecoveryKeyWithPassphraseClosure {
return await generateRecoveryKeyWithPassphraseClosure(passphrase)
} else {
return generateRecoveryKeyReturnValue
return generateRecoveryKeyWithPassphraseReturnValue
}
}
//MARK: - confirmRecoveryKey
@@ -36,7 +36,7 @@ extension SecureBackupControllerMock {
return .success(())
}
generateRecoveryKeyClosure = {
generateRecoveryKeyWithPassphraseClosure = { _ in
recoveryStateSubject.send(.enabled)
return .success("a1B2 C3d4 E5F6 g7H8 i9J0 K1l2 M3n4 O5p6 Q7R8 s9T0 U1v2 W3X4")
}
@@ -19,7 +19,7 @@ enum SecureBackupRecoveryKeyScreenCoordinatorAction {
case complete
}
final class SecureBackupRecoveryKeyScreenCoordinator: CoordinatorProtocol {
final class SecureBackupRecoveryKeyScreenCoordinator: SecureBackupRecoveryKeyCoordinatorProtocol {
private let parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters
private var viewModel: SecureBackupRecoveryKeyScreenViewModelProtocol
@@ -41,7 +41,7 @@ class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewM
state.isGeneratingKey = true
Task {
switch await secureBackupController.generateRecoveryKey() {
switch await secureBackupController.generateRecoveryKey(withPassphrase: nil) {
case .success(let key):
state.recoveryKey = key
case .failure(let error):
@@ -101,7 +101,7 @@ class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewM
}
}
extension SecureBackupRecoveryState {
private extension SecureBackupRecoveryState {
var viewMode: SecureBackupRecoveryKeyScreenViewMode {
switch self {
case .disabled:
@@ -278,9 +278,9 @@ struct SecureBackupRecoveryKeyScreen_Previews: PreviewProvider, TestablePreview
backupController.recoveryState = CurrentValueSubject<SecureBackupRecoveryState, Never>(recoveryState).asCurrentValuePublisher()
if let key {
backupController.generateRecoveryKeyReturnValue = .success(key)
backupController.generateRecoveryKeyWithPassphraseReturnValue = .success(key)
} else {
backupController.generateRecoveryKeyClosure = {
backupController.generateRecoveryKeyWithPassphraseClosure = { _ in
try? await Task.sleep(for: .seconds(1000))
return .success("youshouldntseeme")
}
@@ -110,19 +110,18 @@ class SecureBackupController: SecureBackupControllerProtocol {
return .success(())
}
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError> {
func generateRecoveryKey(withPassphrase passphrase: String?) async -> Result<String, SecureBackupControllerError> {
do {
guard recoveryState.value == .disabled else {
if recoveryState.value == .disabled {
MXLog.info("Enabling recovery")
} else {
MXLog.info("Resetting recovery key")
let key = try await encryption.resetRecoveryKey()
return .success(key)
}
MXLog.info("Enabling recovery")
var keyUploadErrored = false
let recoveryKey = try await encryption.enableRecovery(waitForBackupsToUpload: false, passphrase: nil, progressListener: SDKListener { [weak self] state in
// Note: `enableRecovery` also handles the reset case (equivalent to `resetRecoveryKey`).
// Despite the name, it is the correct call for both initial setup and key rotation.
let recoveryKey = try await encryption.enableRecovery(waitForBackupsToUpload: false, passphrase: passphrase, progressListener: SDKListener { [weak self] state in
guard let self else { return }
switch state {
@@ -55,7 +55,7 @@ protocol SecureBackupControllerProtocol {
func enable() async -> Result<Void, SecureBackupControllerError>
func disable() async -> Result<Void, SecureBackupControllerError>
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError>
func generateRecoveryKey(withPassphrase passphrase: String?) async -> Result<String, SecureBackupControllerError>
func confirmRecoveryKey(_ key: String) async -> Result<Void, SecureBackupControllerError>
func waitForKeyBackupUpload(uploadStateSubject: CurrentValueSubject<SecureBackupSteadyState, Never>) async -> Result<Void, SecureBackupControllerError>
@@ -748,6 +748,7 @@ class MockScreen: Identifiable {
let coordinator = EncryptionSettingsFlowCoordinator(parameters: .init(userSession: userSession,
appSettings: appSettings,
appHooks: AppHooks(),
userIndicatorController: UserIndicatorControllerMock(),
navigationStackCoordinator: navigationStackCoordinator))
retainedState.append(coordinator)