Show recovery instead of verification if this is the last session and recovery is set up

* Show recovery instead of verification if this is the last session and recovery is set up

* Rename `recoveryKeyState` to `recoveryState`

* Remove duplicates on session security state changes.

* Fix missing "Save recovery key" button

* Fix unit tests

* Rename `isLastDevice` to `isOnlyDeviceLeft`

* Address PR comments
This commit is contained in:
Stefan Ceriu
2024-02-16 11:38:49 +02:00
committed by GitHub
parent e0c9f43026
commit db05540cc5
23 changed files with 255 additions and 208 deletions
@@ -166,6 +166,16 @@ class ClientProxy: ClientProxyProtocol {
let digest = SHA256.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}()
func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError> {
do {
let result = try await client.encryption().isLastDevice()
return .success(result)
} catch {
MXLog.error("Failed checking isLastDevice with error: \(error)")
return .failure(.failedCheckingIsLastDevice(error))
}
}
func startSync() {
guard !hasEncounteredAuthError else {
@@ -49,6 +49,7 @@ enum ClientProxyError: Error {
case failedSearchingUsers
case failedGettingUserProfile
case failedSettingUserAvatar
case failedCheckingIsLastDevice(Error?)
}
enum SlidingSyncConstants {
@@ -96,6 +97,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var secureBackupController: SecureBackupControllerProtocol { get }
func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError>
func startSync()
func stopSync()
@@ -42,9 +42,8 @@ class MockClientProxy: ClientProxyProtocol {
lazy var secureBackupController: SecureBackupControllerProtocol = {
let secureBackupController = SecureBackupControllerMock()
secureBackupController.underlyingRecoveryKeyState = .init(CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(.enabled))
secureBackupController.underlyingRecoveryState = .init(CurrentValueSubject<SecureBackupRecoveryState, Never>(.enabled))
secureBackupController.underlyingKeyBackupState = .init(CurrentValueSubject<SecureBackupKeyBackupState, Never>(.enabled))
secureBackupController.isLastSessionReturnValue = .success(false)
return secureBackupController
}()
@@ -54,6 +53,10 @@ class MockClientProxy: ClientProxyProtocol {
self.roomSummaryProvider = roomSummaryProvider
}
func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError> {
.success(false)
}
func startSync() { }
func stopSync() { }
@@ -21,7 +21,7 @@ import MatrixRustSDK
class SecureBackupController: SecureBackupControllerProtocol {
private let encryption: Encryption
private let recoveryKeyStateSubject = CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(.unknown)
private let recoveryStateSubject = CurrentValueSubject<SecureBackupRecoveryState, Never>(.unknown)
private let keyBackupStateSubject = CurrentValueSubject<SecureBackupKeyBackupState, Never>(.unknown)
// periphery:ignore - retaining purpose
@@ -33,8 +33,8 @@ class SecureBackupController: SecureBackupControllerProtocol {
/// Used to dedupe remote backup state requests
@CancellableTask private var remoteBackupStateTask: Task<Void, Error>?
var recoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never> {
recoveryKeyStateSubject.asCurrentValuePublisher()
var recoveryState: CurrentValuePublisher<SecureBackupRecoveryState, Never> {
recoveryStateSubject.asCurrentValuePublisher()
}
var keyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never> {
@@ -76,16 +76,16 @@ class SecureBackupController: SecureBackupControllerProtocol {
switch state {
case .unknown:
recoveryKeyStateSubject.send(.unknown)
recoveryStateSubject.send(.unknown)
case .enabled:
recoveryKeyStateSubject.send(.enabled)
recoveryStateSubject.send(.enabled)
case .disabled:
recoveryKeyStateSubject.send(.disabled)
recoveryStateSubject.send(.disabled)
case .incomplete:
recoveryKeyStateSubject.send(.incomplete)
recoveryStateSubject.send(.incomplete)
}
MXLog.info("Recovery state changed to: \(state), setting local state to \(recoveryKeyStateSubject.value)")
MXLog.info("Recovery state changed to: \(state), setting local state to \(recoveryStateSubject.value)")
})
updateBackupStateFromRemote()
@@ -120,7 +120,7 @@ class SecureBackupController: SecureBackupControllerProtocol {
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError> {
do {
guard recoveryKeyState.value == .disabled else {
guard recoveryState.value == .disabled else {
MXLog.info("Resetting recovery key")
let key = try await encryption.resetRecoveryKey()
@@ -135,9 +135,9 @@ class SecureBackupController: SecureBackupControllerProtocol {
switch state {
case .starting, .creatingBackup, .creatingRecoveryKey, .backingUp:
recoveryKeyStateSubject.send(.settingUp)
recoveryStateSubject.send(.settingUp)
case .done:
recoveryKeyStateSubject.send(.enabled)
recoveryStateSubject.send(.enabled)
case .roomKeyUploadError:
MXLog.error("Failed enabling recovery: room key upload error")
keyUploadErrored = true
@@ -162,17 +162,7 @@ class SecureBackupController: SecureBackupControllerProtocol {
return .failure(.failedConfirmingRecoveryKey)
}
}
func isLastSession() async -> Result<Bool, SecureBackupControllerError> {
do {
MXLog.info("Checking if last session")
return try await .success(encryption.isLastDevice())
} catch {
MXLog.error("Failed checking if last session with error: \(error)")
return .failure(.failedFetchingSessionState)
}
}
func waitForKeyBackupUpload() async -> Result<Void, SecureBackupControllerError> {
do {
MXLog.info("Waiting for backup upload steady state")
@@ -17,7 +17,7 @@
import Combine
import Foundation
enum SecureBackupRecoveryKeyState {
enum SecureBackupRecoveryState {
case unknown
case disabled
case enabled
@@ -50,7 +50,7 @@ enum SecureBackupControllerError: Error {
// sourcery: AutoMockable
protocol SecureBackupControllerProtocol {
var recoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never> { get }
var recoveryState: CurrentValuePublisher<SecureBackupRecoveryState, Never> { get }
var keyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never> { get }
@@ -60,7 +60,5 @@ protocol SecureBackupControllerProtocol {
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError>
func confirmRecoveryKey(_ key: String) async -> Result<Void, SecureBackupControllerError>
func isLastSession() async -> Result<Bool, SecureBackupControllerError>
func waitForKeyBackupUpload() async -> Result<Void, SecureBackupControllerError>
}
@@ -25,5 +25,5 @@ struct MockUserSession: UserSessionProtocol {
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
var sessionVerificationState: CurrentValuePublisher<Bool?, Never> = .init(.init(true))
var sessionSecurityStatePublisher: AnyPublisher<SessionSecurityState, Never> = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .verified, recoveryState: .enabled)).eraseToAnyPublisher()
}
@@ -18,10 +18,12 @@ import Combine
import Foundation
class UserSession: UserSessionProtocol {
private let sessionVerificationStateSubject: CurrentValueSubject<SessionVerificationState, Never> = .init(.unknown)
private var cancellables = Set<AnyCancellable>()
private var checkSessionVerificationStateCancellable: AnyCancellable?
private var checkSessionVerificationStateTask: Task<Void, Never>?
private var retrieveSessionVerificationControllerTask: Task<Void, Never>?
private var authErrorCancellable: AnyCancellable?
@@ -40,7 +42,7 @@ class UserSession: UserSessionProtocol {
sessionVerificationController?.callbacks.sink { [weak self] callback in
switch callback {
case .finished:
self?.sessionVerificationStateSubject.send(true)
self?.sessionVerificationStateSubject.send(.verified)
default:
break
}
@@ -49,74 +51,31 @@ class UserSession: UserSessionProtocol {
}
}
private var sessionVerificationStateSubject: CurrentValueSubject<Bool?, Never> = .init(nil)
var sessionVerificationState: CurrentValuePublisher<Bool?, Never> {
sessionVerificationStateSubject.asCurrentValuePublisher()
}
let sessionSecurityStatePublisher: AnyPublisher<SessionSecurityState, Never>
init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) {
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
self.voiceMessageMediaManager = voiceMessageMediaManager
setupSessionVerificationWatchdog()
setupAuthErrorWatchdog()
}
// MARK: - Private
private func setupSessionVerificationWatchdog() {
checkSessionVerificationStateCancellable = clientProxy.callbacks
sessionSecurityStatePublisher = Publishers.CombineLatest(sessionVerificationStateSubject, clientProxy.secureBackupController.recoveryState)
.map {
MXLog.info("Session security state changed, verificationState: \($0), recoveryState: \($1)")
return SessionSecurityState(verificationState: $0, recoveryState: $1)
}
.removeDuplicates()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
if callback.isSyncUpdate {
self?.attemptSessionVerification()
self?.checkSessionVerificationState()
}
}
}
private func attemptSessionVerification() {
guard checkSessionVerificationStateTask == nil else {
MXLog.info("Session verification state check already in progress")
return
}
.store(in: &cancellables)
MXLog.info("Checking session verification state")
checkSessionVerificationStateTask = Task {
MXLog.info("Retrieving session verification controller")
switch await clientProxy.sessionVerificationControllerProxy() {
case .success(let sessionVerificationController):
MXLog.info("Retrieving session verification state")
guard case let .success(isVerified) = await sessionVerificationController.isVerified() else {
MXLog.error("Failed checking verification state. Will retry on the next sync update.")
return
}
tearDownSessionVerificationControllerWatchdog()
self.sessionVerificationController = sessionVerificationController
MXLog.info("Session verified: \(isVerified)")
sessionVerificationStateSubject.send(isVerified)
checkSessionVerificationStateTask = nil
case .failure(let error):
MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
}
}
}
private func tearDownSessionVerificationControllerWatchdog() {
checkSessionVerificationStateCancellable = nil
}
// MARK: Auth Error Watchdog
private func setupAuthErrorWatchdog() {
authErrorCancellable = clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
@@ -124,14 +83,68 @@ class UserSession: UserSessionProtocol {
switch callback {
case .receivedAuthError(let isSoftLogout):
callbacks.send(.didReceiveAuthError(isSoftLogout: isSoftLogout))
tearDownAuthErrorWatchdog()
authErrorCancellable = nil
default:
break
}
}
}
private func tearDownAuthErrorWatchdog() {
authErrorCancellable = nil
// MARK: - Private
private func checkSessionVerificationState() {
guard retrieveSessionVerificationControllerTask == nil else {
MXLog.info("Session verification state check already in progress")
return
}
guard sessionVerificationController == nil else {
Task {
await updateSessionVerificationState()
}
return
}
MXLog.info("Retrieving session verification controller")
retrieveSessionVerificationControllerTask = Task {
switch await clientProxy.sessionVerificationControllerProxy() {
case .success(let sessionVerificationController):
self.sessionVerificationController = sessionVerificationController
await updateSessionVerificationState()
retrieveSessionVerificationControllerTask = nil
case .failure(let error):
MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
}
}
}
private func updateSessionVerificationState() async {
guard let sessionVerificationController else {
fatalError("This point should never be reached")
}
MXLog.info("Checking session verification state")
guard case let .success(isVerified) = await sessionVerificationController.isVerified() else {
MXLog.error("Failed checking verification state. Will retry on the next sync update.")
return
}
if isVerified {
sessionVerificationStateSubject.send(.verified)
} else {
guard case let .success(isLastDevice) = await clientProxy.isOnlyDeviceLeft() else {
MXLog.error("Failed checking isLastDevice. Will retry on the next sync update.")
return
}
if isLastDevice {
sessionVerificationStateSubject.send(.unverifiedLastSession)
} else {
sessionVerificationStateSubject.send(.unverified)
}
}
}
}
@@ -21,6 +21,18 @@ enum UserSessionCallback {
case didReceiveAuthError(isSoftLogout: Bool)
}
enum SessionVerificationState {
case unknown
case verified
case unverified
case unverifiedLastSession
}
struct SessionSecurityState: Equatable {
let verificationState: SessionVerificationState
let recoveryState: SecureBackupRecoveryState
}
protocol UserSessionProtocol {
var homeserver: String { get }
var userID: String { get }
@@ -30,7 +42,7 @@ protocol UserSessionProtocol {
var mediaProvider: MediaProviderProtocol { get }
var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get }
var sessionVerificationState: CurrentValuePublisher<Bool?, Never> { get }
var sessionSecurityStatePublisher: AnyPublisher<SessionSecurityState, Never> { get }
var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get }
var callbacks: PassthroughSubject<UserSessionCallback, Never> { get }