From cb1a23c66467bd2d9db9a2007329cf04947a68ca Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 7 Jul 2026 17:44:53 +0200 Subject: [PATCH 01/56] chore: Move DerivedTokenGenerator to the Auth module --- .../com/infomaniak/core/auth}/DerivedTokenGenerator.kt | 4 ++-- .../infomaniak/core/auth}/DerivedTokenGeneratorImpl.kt | 10 +++++----- .../crossapplogin/back/BaseCrossAppLoginViewModel.kt | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) rename {CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back => Auth/src/main/kotlin/com/infomaniak/core/auth}/DerivedTokenGenerator.kt (93%) rename {CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back => Auth/src/main/kotlin/com/infomaniak/core/auth}/DerivedTokenGeneratorImpl.kt (97%) diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt similarity index 93% rename from CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt rename to Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt index 5cd49fb54..9921ae7bd 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt @@ -15,14 +15,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.core.crossapplogin.back +package com.infomaniak.core.auth import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException import com.infomaniak.core.common.Xor import com.infomaniak.core.login.ApiToken import okhttp3.Response -internal sealed interface DerivedTokenGenerator { +sealed interface DerivedTokenGenerator { suspend fun attemptDerivingOneOfTheseTokens(tokensToTry: Set): Xor suspend fun isAppIntegrityGuaranteedToFail(): Boolean diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt similarity index 97% rename from CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt rename to Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt index dab0fe003..c30653b7f 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt @@ -15,22 +15,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.core.crossapplogin.back +package com.infomaniak.core.auth import com.infomaniak.core.appintegrity.AppIntegrityIssue import com.infomaniak.core.appintegrity.AppIntegrityManager import com.infomaniak.core.appintegrity.AppIntegrityManager.Companion.APP_INTEGRITY_MANAGER_TAG import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException import com.infomaniak.core.appintegrity.exceptions.NetworkException +import com.infomaniak.core.auth.DerivedTokenGenerator.Issue import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable -import com.infomaniak.core.crossapplogin.back.DerivedTokenGenerator.Issue +import com.infomaniak.core.login.ApiToken +import com.infomaniak.core.login.InfomaniakLogin import com.infomaniak.core.network.api.ApiController import com.infomaniak.core.network.utils.await import com.infomaniak.core.network.utils.bodyAsStringOrNull import com.infomaniak.core.sentry.SentryLog -import com.infomaniak.core.login.ApiToken -import com.infomaniak.core.login.InfomaniakLogin import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request @@ -39,7 +39,7 @@ import splitties.init.appCtx import java.io.IOException import kotlin.uuid.ExperimentalUuidApi -internal class DerivedTokenGeneratorImpl( +class DerivedTokenGeneratorImpl( private val tokenRetrievalUrl: String, private val hostAppPackageName: String, private val clientId: String, diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 5bc7f9492..0b1af870c 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -20,6 +20,9 @@ package com.infomaniak.core.crossapplogin.back import androidx.activity.ComponentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.DerivedTokenGenerator.Issue +import com.infomaniak.core.auth.DerivedTokenGeneratorImpl import com.infomaniak.core.auth.api.ApiRepositoryCore import com.infomaniak.core.auth.api.ApiRoutesCore.TOKEN_URL import com.infomaniak.core.common.Xor @@ -31,7 +34,6 @@ import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade.AccountsChecki import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade.AccountsCheckingStatus import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade.AccountsCheckingStatus.* import com.infomaniak.core.crossapplogin.back.CrossAppLoginFacade.LoginResult -import com.infomaniak.core.crossapplogin.back.DerivedTokenGenerator.Issue import com.infomaniak.core.crossapplogin.back.internal.CustomTokenInterceptor import com.infomaniak.core.login.ApiToken import com.infomaniak.core.network.models.exceptions.NetworkException From 6bf4b7bca7b352f60425ccb1aa4ba457172acdd1 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 7 Jul 2026 18:49:49 +0200 Subject: [PATCH 02/56] feat: Introduce the RestoreFromBackupManager interface --- .../core/auth/RestoreFromBackupManager.kt | 51 +++++++++++++++++++ .../infomaniak/core/auth/TokenInterceptor.kt | 1 + 2 files changed, 52 insertions(+) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt new file mode 100644 index 000000000..2663c56b9 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt @@ -0,0 +1,51 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth + +import kotlinx.coroutines.flow.SharedFlow + +sealed class RestoreFromBackupManager { + + abstract val state: SharedFlow + + abstract suspend fun ensureRestorationIsHandled() + + sealed interface State { + + data object Settled : State + + data object RestoringFromBackup : State + + /** + * @property giveUp Gives up restoring all accounts that failed, and disconnects them. + */ + data class RestoringFromBackupFailed( + val cause: Issue, + val retry: () -> Unit, + val giveUp: () -> Unit, + ) : State { + sealed interface Issue { // Find a way to have these defined somewhere once and for all. + data object NetworkIssue : Issue + } + } + } + + companion object { + val instance: RestoreFromBackupManager = TODO() + } +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt index b43ab8dca..018ffdd67 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt @@ -38,6 +38,7 @@ class TokenInterceptor( var request = chain.request() runBlocking(Dispatchers.Default) { + RestoreFromBackupManager.instance.ensureRestorationIsHandled() tokenInterceptorListener.getApiToken() }?.let { apiToken -> val authorization = request.header("Authorization") From a33e9dc1c210174d6d1aeb4632d31282b0865976 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 8 Jul 2026 18:07:22 +0200 Subject: [PATCH 03/56] chore: Add suspending allUsers() in UserDao This commit also renames LiveData returning getAll() to allAsLiveData() --- .../kotlin/com/infomaniak/core/auth/CredentialManager.kt | 2 +- .../src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt index cb41aac74..39d357557 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt @@ -34,7 +34,7 @@ abstract class CredentialManager : BaseCredentialManager() { abstract val currentUserId: Int abstract var currentUser: User? - fun getAllUsers(): LiveData> = userDatabase.userDao().getAll() + fun getAllUsers(): LiveData> = userDatabase.userDao().allAsLiveData() suspend fun getAllUsersCount(): Int = userDatabase.userDao().userCount() diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt index afee982e1..6b1b91405 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt @@ -31,7 +31,10 @@ import kotlinx.coroutines.flow.Flow @Dao interface UserDao { @Query("SELECT * FROM user") - fun getAll(): LiveData> + fun allAsLiveData(): LiveData> + + @Query("SELECT * FROM user") + suspend fun allUsers(): List @get:Query("SELECT * FROM user") val allUsers: Flow> From 32eccb99a55b76d0e411281a742893855455d445 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 8 Jul 2026 18:07:54 +0200 Subject: [PATCH 04/56] feat: Add WIP RestoreFromBackupManagerImpl --- Auth/build.gradle.kts | 1 + .../core/auth/DerivedTokenGeneratorImpl.kt | 2 +- .../infomaniak/core/auth/TokenInterceptor.kt | 1 + .../auth/backup/AccountsRestorationState.kt | 31 +++++ .../{ => backup}/RestoreFromBackupManager.kt | 4 +- .../backup/RestoreFromBackupManagerImpl.kt | 126 ++++++++++++++++++ .../com/infomaniak/core/common/AndroidId.kt | 28 ++++ .../deviceid/SharedDeviceIdStorage.kt | 8 +- 8 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt rename Auth/src/main/kotlin/com/infomaniak/core/auth/{ => backup}/RestoreFromBackupManager.kt (92%) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/AndroidId.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index ea9f69978..7bc7a7b79 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(core.appcompat) implementation(core.androidx.core.ktx) implementation(core.kotlinx.serialization.json) + implementation(core.kotlinx.serialization.protobuf) implementation(core.gson) implementation(core.splitties.appctx) implementation(core.okhttp) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt index c30653b7f..fa9c81f2b 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGeneratorImpl.kt @@ -41,7 +41,7 @@ import kotlin.uuid.ExperimentalUuidApi class DerivedTokenGeneratorImpl( private val tokenRetrievalUrl: String, - private val hostAppPackageName: String, + private val hostAppPackageName: String = appCtx.packageName, private val clientId: String, private val userAgent: String, private val accessType: InfomaniakLogin.AccessType? = null, diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt index 018ffdd67..f98a18c5f 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt @@ -18,6 +18,7 @@ package com.infomaniak.core.auth import com.infomaniak.core.auth.TokenAuthenticator.Companion.changeAccessToken +import com.infomaniak.core.auth.backup.RestoreFromBackupManager import com.infomaniak.core.network.api.ApiController.json import com.infomaniak.core.network.api.ApiController.toApiError import com.infomaniak.core.network.api.InternalTranslatedErrorCode diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt new file mode 100644 index 000000000..576abfb5f --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt @@ -0,0 +1,31 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth.backup + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +internal data class AccountsRestorationState( + @ProtoNumber(1) + val androidId: String, + @ProtoNumber(2) + val restoredAccountIds: Set, +) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt similarity index 92% rename from Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt rename to Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 2663c56b9..2dfbb30f8 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.core.auth +package com.infomaniak.core.auth.backup import kotlinx.coroutines.flow.SharedFlow @@ -46,6 +46,6 @@ sealed class RestoreFromBackupManager { } companion object { - val instance: RestoreFromBackupManager = TODO() + val instance: RestoreFromBackupManager = RestoreFromBackupManagerImpl() } } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt new file mode 100644 index 000000000..8dc832723 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -0,0 +1,126 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +@file:OptIn(ExperimentalSplittiesApi::class, ExperimentalSerializationApi::class) + +package com.infomaniak.core.auth.backup + +import androidx.core.util.AtomicFile +import com.infomaniak.core.auth.AuthConfiguration.clientId +import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.DerivedTokenGeneratorImpl +import com.infomaniak.core.auth.api.ApiRoutesCore.TOKEN_URL +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.extensions.write +import com.infomaniak.core.common.getAndroidId +import com.infomaniak.core.network.networking.HttpUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.invoke +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import splitties.experimental.ExperimentalSplittiesApi +import splitties.init.appCtx +import java.io.FileNotFoundException + +internal class RestoreFromBackupManagerImpl( + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : RestoreFromBackupManager() { + + private val restorationStateFile = AtomicFile(appCtx.filesDir.resolve("accountsRestorationState")) + + private val derivedTokenGenerator: DerivedTokenGenerator = DerivedTokenGeneratorImpl( + coroutineScope = coroutineScope, + tokenRetrievalUrl = TOKEN_URL, + clientId = clientId, + userAgent = HttpUtils.getUserAgent, + ) + + override val state: SharedFlow = flow { + try { + val lastSavedRestorationState = readLastSavedRestorationState() + performRestorationHandlingIfNeeded(lastSavedRestorationState) + } catch (_: FileNotFoundException) { + saveNoRestorationInProgress() + } + emit(State.Settled) + }.shareIn(coroutineScope, SharingStarted.Eagerly) + + override suspend fun ensureRestorationIsHandled() { + state.first { it is State.Settled } + } + + private suspend fun FlowCollector.performRestorationHandlingIfNeeded(lastState: AccountsRestorationState) { + val users = UserDatabase.instance.userDao().allUsers() + val currentAndroidId = getAndroidId() + val deviceChanged = currentAndroidId != lastState.androidId + restoreAccounts( + currentAndroidId = currentAndroidId, + alreadyRestoredAccountIds = if (deviceChanged) emptySet() else lastState.restoredAccountIds, + allUsers = users + ) + } + + private suspend fun FlowCollector.restoreAccounts( + currentAndroidId: String, + alreadyRestoredAccountIds: Set, + allUsers: List, + ) { + val allUsersIds = allUsers.mapTo(hashSetOf()) { it.id.toLong() } + if (alreadyRestoredAccountIds.containsAll(allUsersIds)) return + emit(State.RestoringFromBackup) + saveRestorationState( + AccountsRestorationState( + androidId = currentAndroidId, + restoredAccountIds = alreadyRestoredAccountIds + ) + ) + TODO("Loop trying to derive tokens for remaining accounts. Allow retrying or giving-up if needed on each iteration") + } + + private suspend fun saveNoRestorationInProgress() { + val users = UserDatabase.instance.userDao().allUsers() + // To avoid running a token derivation, we mark it as done if we + // didn't have it before. + val data = AccountsRestorationState( + androidId = getAndroidId(), + restoredAccountIds = users.mapTo(hashSetOf()) { it.id.toLong() } + ) + saveRestorationState(data) + } + + private suspend fun readLastSavedRestorationState(): AccountsRestorationState = Dispatchers.IO { + restorationStateFile.openRead().use { stream -> + ProtoBuf.decodeFromByteArray(stream.readBytes()) + } + } + + private suspend fun saveRestorationState(currentState: AccountsRestorationState) = Dispatchers.IO { + restorationStateFile.write { outputStream -> + outputStream.write(ProtoBuf.encodeToByteArray(currentState)) + } + } +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/AndroidId.kt b/Common/src/main/kotlin/com/infomaniak/core/common/AndroidId.kt new file mode 100644 index 000000000..85c2e7f03 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/AndroidId.kt @@ -0,0 +1,28 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common + +import android.provider.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.invoke +import splitties.init.appCtx + +suspend fun getAndroidId() = Dispatchers.IO { + @Suppress("HardwareIds") + Settings.Secure.getString(appCtx.contentResolver, Settings.Secure.ANDROID_ID) +} diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceid/SharedDeviceIdStorage.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceid/SharedDeviceIdStorage.kt index b462b9b5f..6c7f387b2 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceid/SharedDeviceIdStorage.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceid/SharedDeviceIdStorage.kt @@ -17,10 +17,9 @@ */ package com.infomaniak.core.crossapplogin.back.internal.deviceid -import android.provider.Settings -import android.provider.Settings.Secure.ANDROID_ID import androidx.core.util.AtomicFile import com.infomaniak.core.common.extensions.write +import com.infomaniak.core.common.getAndroidId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -106,9 +105,4 @@ internal object SharedDeviceIdStorage { @ProtoNumber(1) val androidId: String, @ProtoNumber(2) val uuid: ByteArray, ) - - private suspend fun getAndroidId() = Dispatchers.IO { - @Suppress("HardwareIds") - Settings.Secure.getString(appCtx.contentResolver, ANDROID_ID) - } } From ac89757fe2b218ff2de2b9c6b9b111a946fdaaec Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 9 Jul 2026 18:38:36 +0200 Subject: [PATCH 05/56] chore: Forward issues from DerivedTokenGenerator in RestoreFromBackupManager --- .../core/auth/backup/RestoreFromBackupManager.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 2dfbb30f8..6a7459bd8 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.core.auth.backup +import com.infomaniak.core.auth.DerivedTokenGenerator import kotlinx.coroutines.flow.SharedFlow sealed class RestoreFromBackupManager { @@ -35,14 +36,10 @@ sealed class RestoreFromBackupManager { * @property giveUp Gives up restoring all accounts that failed, and disconnects them. */ data class RestoringFromBackupFailed( - val cause: Issue, + val cause: DerivedTokenGenerator.Issue, val retry: () -> Unit, val giveUp: () -> Unit, - ) : State { - sealed interface Issue { // Find a way to have these defined somewhere once and for all. - data object NetworkIssue : Issue - } - } + ) : State } companion object { From 1ef83db9533e7db5e2236a80cdda6016fee3b122 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 9 Jul 2026 18:57:25 +0200 Subject: [PATCH 06/56] chore: Store user token to device association in the db instead of a file --- Auth/build.gradle.kts | 1 - .../10.json | 213 ++++++++++++++++++ ... DerivedTokenGenerator.IssueExtensions.kt} | 20 +- .../infomaniak/core/auth/UserAccountUtils.kt | 11 +- .../backup/RestoreFromBackupManagerImpl.kt | 146 +++++++----- .../core/auth/models/TokenDeviceBinding.kt | 40 ++++ .../com/infomaniak/core/auth/room/UserDao.kt | 7 + .../infomaniak/core/auth/room/UserDatabase.kt | 7 +- .../back/BaseCrossAppLoginViewModel.kt | 24 +- 9 files changed, 382 insertions(+), 87 deletions(-) create mode 100644 Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json rename Auth/src/main/kotlin/com/infomaniak/core/auth/{backup/AccountsRestorationState.kt => DerivedTokenGenerator.IssueExtensions.kt} (65%) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/models/TokenDeviceBinding.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 7bc7a7b79..ea9f69978 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -50,7 +50,6 @@ dependencies { implementation(core.appcompat) implementation(core.androidx.core.ktx) implementation(core.kotlinx.serialization.json) - implementation(core.kotlinx.serialization.protobuf) implementation(core.gson) implementation(core.splitties.appctx) implementation(core.okhttp) diff --git a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json new file mode 100644 index 000000000..c41246e5b --- /dev/null +++ b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json @@ -0,0 +1,213 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6d4be66bdbd5459afb46f129e14738f7", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `firstname` TEXT NOT NULL, `lastname` TEXT NOT NULL, `email` TEXT NOT NULL, `avatar` TEXT, `phones` TEXT, `card` TEXT, `login` TEXT NOT NULL, `isStaff` INTEGER NOT NULL DEFAULT false, `organizations` TEXT NOT NULL, `preferences_security_score` INTEGER DEFAULT 0, `preferences_security_dateLastChangedPassword` INTEGER DEFAULT 0, `preferences_organizationPreference_currentOrganizationId` INTEGER NOT NULL DEFAULT 0, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `tokenType` TEXT NOT NULL, `expiresIn` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `scope` TEXT, `expiresAt` INTEGER, `isTemporary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "firstname", + "columnName": "firstname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastname", + "columnName": "lastname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT" + }, + { + "fieldPath": "phones", + "columnName": "phones", + "affinity": "TEXT" + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT" + }, + { + "fieldPath": "login", + "columnName": "login", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "organizations", + "columnName": "organizations", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preferences.security.score", + "columnName": "preferences_security_score", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "preferences.security.dateLastChangedPassword", + "columnName": "preferences_security_dateLastChangedPassword", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "preferences.organizationPreference.currentOrganizationId", + "columnName": "preferences_organizationPreference_currentOrganizationId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "apiToken.accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiToken.refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT" + }, + { + "fieldPath": "apiToken.tokenType", + "columnName": "tokenType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiToken.expiresIn", + "columnName": "expiresIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken.userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken.scope", + "columnName": "scope", + "affinity": "TEXT" + }, + { + "fieldPath": "apiToken.expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "apiToken.isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CurrentUserId", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `uniqueKey` TEXT NOT NULL, PRIMARY KEY(`uniqueKey`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "uniqueKey", + "columnName": "uniqueKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uniqueKey" + ] + } + }, + { + "tableName": "TokenDeviceBinding", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `androidId` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6d4be66bdbd5459afb46f129e14738f7')" + ] + } +} \ No newline at end of file diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt similarity index 65% rename from Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt rename to Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index 576abfb5f..9130fe147 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -15,17 +15,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.core.auth.backup +package com.infomaniak.core.auth -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.protobuf.ProtoNumber +import com.infomaniak.core.auth.DerivedTokenGenerator.Issue -@ExperimentalSerializationApi -@Serializable -internal data class AccountsRestorationState( - @ProtoNumber(1) - val androidId: String, - @ProtoNumber(2) - val restoredAccountIds: Set, -) +fun Issue.shouldReport(): Boolean = when (this) { + is Issue.AppIntegrityCheckFailed -> false + is Issue.ErrorResponse -> response.code !in 500..599 + is Issue.NetworkIssue -> false + is Issue.OtherIssue -> true +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt index e92549ee9..9f3367709 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt @@ -20,9 +20,13 @@ package com.infomaniak.core.auth import android.content.Context import android.database.sqlite.SQLiteConstraintException import androidx.annotation.CallSuper +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.AssociatedUserDataCleanable +import com.infomaniak.core.common.getAndroidId /** * This class factorises the addition, removal and listing of users inside of a [UserDatabase]. @@ -42,7 +46,12 @@ open class UserAccountUtils( @CallSuper open suspend fun addUser(user: User) { userDataCleanableList.forEach { it.resetForUser(user.id.toLong()) } - userDao.insert(user) + userDatabase.useWriterConnection { + it.immediateTransaction { + userDao.insert(user) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, getAndroidId())) + } + } } @CallSuper diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 8dc832723..44f378f58 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -19,38 +19,43 @@ package com.infomaniak.core.auth.backup -import androidx.core.util.AtomicFile +import androidx.room.immediateTransaction +import androidx.room.useWriterConnection import com.infomaniak.core.auth.AuthConfiguration.clientId import com.infomaniak.core.auth.DerivedTokenGenerator import com.infomaniak.core.auth.DerivedTokenGeneratorImpl import com.infomaniak.core.auth.api.ApiRoutesCore.TOKEN_URL +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase -import com.infomaniak.core.common.extensions.write +import com.infomaniak.core.auth.shouldReport +import com.infomaniak.core.common.Xor import com.infomaniak.core.common.getAndroidId +import com.infomaniak.core.login.ApiToken import com.infomaniak.core.network.networking.HttpUtils +import com.infomaniak.core.sentry.SentryLog +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.invoke import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.protobuf.ProtoBuf import splitties.experimental.ExperimentalSplittiesApi -import splitties.init.appCtx -import java.io.FileNotFoundException internal class RestoreFromBackupManagerImpl( private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), ) : RestoreFromBackupManager() { - private val restorationStateFile = AtomicFile(appCtx.filesDir.resolve("accountsRestorationState")) + private val userDb = UserDatabase.instance + private val userDao = userDb.userDao() private val derivedTokenGenerator: DerivedTokenGenerator = DerivedTokenGeneratorImpl( coroutineScope = coroutineScope, @@ -60,67 +65,104 @@ internal class RestoreFromBackupManagerImpl( ) override val state: SharedFlow = flow { - try { - val lastSavedRestorationState = readLastSavedRestorationState() - performRestorationHandlingIfNeeded(lastSavedRestorationState) - } catch (_: FileNotFoundException) { - saveNoRestorationInProgress() - } + performRestorationHandlingIfNeeded() emit(State.Settled) - }.shareIn(coroutineScope, SharingStarted.Eagerly) + }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly) override suspend fun ensureRestorationIsHandled() { state.first { it is State.Settled } } - private suspend fun FlowCollector.performRestorationHandlingIfNeeded(lastState: AccountsRestorationState) { - val users = UserDatabase.instance.userDao().allUsers() + private suspend fun FlowCollector.performRestorationHandlingIfNeeded() { + val users = userDao.allUsers() val currentAndroidId = getAndroidId() - val deviceChanged = currentAndroidId != lastState.androidId - restoreAccounts( - currentAndroidId = currentAndroidId, - alreadyRestoredAccountIds = if (deviceChanged) emptySet() else lastState.restoredAccountIds, - allUsers = users - ) + restoreAccounts(currentAndroidId = currentAndroidId, allUsers = users) } - private suspend fun FlowCollector.restoreAccounts( + private tailrec suspend fun FlowCollector.restoreAccounts( currentAndroidId: String, - alreadyRestoredAccountIds: Set, allUsers: List, ) { - val allUsersIds = allUsers.mapTo(hashSetOf()) { it.id.toLong() } - if (alreadyRestoredAccountIds.containsAll(allUsersIds)) return + val usersToDeriveTokensFor: List = coroutineScope { + allUsers.map { user -> + async { + val currentBinding = userDao.getTokenDeviceBindingForUser(user.id) + when { + currentBinding == null -> { + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + null // Adding missing valid binding (post app update). + } + currentBinding.androidId == currentAndroidId -> null // Already valid. + else -> user // Device changed. Need to derive token. + } + } + } + }.awaitAll().filterNotNull() + + if (usersToDeriveTokensFor.isEmpty()) return + emit(State.RestoringFromBackup) - saveRestorationState( - AccountsRestorationState( - androidId = currentAndroidId, - restoredAccountIds = alreadyRestoredAccountIds - ) - ) - TODO("Loop trying to derive tokens for remaining accounts. Allow retrying or giving-up if needed on each iteration") - } - private suspend fun saveNoRestorationInProgress() { - val users = UserDatabase.instance.userDao().allUsers() - // To avoid running a token derivation, we mark it as done if we - // didn't have it before. - val data = AccountsRestorationState( - androidId = getAndroidId(), - restoredAccountIds = users.mapTo(hashSetOf()) { it.id.toLong() } - ) - saveRestorationState(data) - } + val issuesWithUser = coroutineScope { + usersToDeriveTokensFor.map { user -> + async { + when (val result = attemptRestoringAccount(user)) { + is Xor.First -> userDb.useWriterConnection { + it.immediateTransaction { + userDao.update(user.copy(apiToken = result.value)) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + } + null + } + is Xor.Second -> result.value to user + } + } + } + }.awaitAll().filterNotNull() + + if (issuesWithUser.isEmpty()) return - private suspend fun readLastSavedRestorationState(): AccountsRestorationState = Dispatchers.IO { - restorationStateFile.openRead().use { stream -> - ProtoBuf.decodeFromByteArray(stream.readBytes()) + val shouldRetryAsync = CompletableDeferred() + val failedState = State.RestoringFromBackupFailed( + cause = issuesWithUser.first().first, + retry = { shouldRetryAsync.complete(true) }, + giveUp = { shouldRetryAsync.complete(false) }, + ) + emit(failedState) + val shouldRetry = shouldRetryAsync.await() + val giveUp = !shouldRetry + if (giveUp) { + userDb.useWriterConnection { + it.immediateTransaction { + TODO("removeUser from the db, and ensure associated data gets removed too") + //TODO: For kDrive, that would be removing: + // - MyKSuite data (MyKSuiteDataUtils.deleteData(…)) + // - everything called in AccountUtils.removeUser + // Maybe we need to give the ability to register a callback in the apps, + // as well as having a system to put data back in place when the app + // process starts with orphan user data (i.e/ user-tied data that is not in the User table) + } + } + return } + restoreAccounts(currentAndroidId = currentAndroidId, allUsers = allUsers) } - private suspend fun saveRestorationState(currentState: AccountsRestorationState) = Dispatchers.IO { - restorationStateFile.write { outputStream -> - outputStream.write(ProtoBuf.encodeToByteArray(currentState)) + private suspend fun attemptRestoringAccount(user: User): Xor { + return derivedTokenGenerator.attemptDerivingOneOfTheseTokens(setOf(user.apiToken.accessToken)).also { result -> + if (result !is Xor.Second) return@also + val issue = result.value + val errorMessage = "Failed to derive token" + val sentryUser = io.sentry.protocol.User().also { it.id = user.id.toString() } + if (result.value.shouldReport()) { + SentryLog.e(TAG, errorMessage, (issue as? DerivedTokenGenerator.Issue.OtherIssue)?.e) { scope -> + scope.user = sentryUser + } + } else { + SentryLog.i(TAG, "$errorMessage for user ${user.id}, with reason: $issue") + } } } } + +private const val TAG = "RestoreFromBackupManagerImpl" diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/models/TokenDeviceBinding.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/TokenDeviceBinding.kt new file mode 100644 index 000000000..78b06964c --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/TokenDeviceBinding.kt @@ -0,0 +1,40 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth.models + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.infomaniak.core.auth.models.user.User + +/** + * Records the [androidId] of the device where the [User.apiToken] was originally generated. + * + * If this [androidId] differs from the current device ID (see [com.infomaniak.core.common.getAndroidId]), + * it indicates that app data was restored on a new device or after a factory reset. + * + * In this scenario, the token must be re-derived to prevent credential conflicts between devices. + */ +@Entity( + foreignKeys = [ForeignKey(User::class, parentColumns = ["id"], childColumns = ["userId"], onDelete = ForeignKey.CASCADE)] +) +data class TokenDeviceBinding( + @PrimaryKey + val userId: Int, + val androidId: String, +) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt index 6b1b91405..65357daa1 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt @@ -24,6 +24,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update import androidx.room.Upsert +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.User import kotlinx.coroutines.flow.Flow @@ -88,4 +89,10 @@ interface UserDao { @Query("DELETE FROM user WHERE id = :userId") suspend fun deleteUserById(userId: Int) + @Upsert + suspend fun upsertTokenDeviceBinding(binding: TokenDeviceBinding) + + @Query("SELECT * FROM TokenDeviceBinding WHERE userId=:userId") + suspend fun getTokenDeviceBindingForUser(userId: Int): TokenDeviceBinding? + } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt index b3aa67693..708044a40 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt @@ -30,13 +30,14 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.infomaniak.core.auth.models.CurrentUserId import com.infomaniak.core.auth.models.OrganizationAccount +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.Phone import com.infomaniak.core.auth.models.user.User import splitties.init.appCtx @Database( - entities = [User::class, CurrentUserId::class], + entities = [User::class, CurrentUserId::class, TokenDeviceBinding::class], autoMigrations = [ AutoMigration( from = 1, to = 2, @@ -55,11 +56,11 @@ import splitties.init.appCtx ), AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), ], - version = 9, + version = 10, exportSchema = true ) - @TypeConverters(UserConverter::class) abstract class UserDatabase internal constructor() : RoomDatabase() { diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 0b1af870c..8400c6c50 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -25,6 +25,7 @@ import com.infomaniak.core.auth.DerivedTokenGenerator.Issue import com.infomaniak.core.auth.DerivedTokenGeneratorImpl import com.infomaniak.core.auth.api.ApiRepositoryCore import com.infomaniak.core.auth.api.ApiRoutesCore.TOKEN_URL +import com.infomaniak.core.auth.shouldReport import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.completableScope @@ -319,24 +320,11 @@ internal class CrossAppLoginFacadeImpl( // @StringRes doesn't work with a suspend function because they technically return java.lang.Object private suspend fun getTokenDerivationIssueErrorMessage(account: ExternalAccount, issue: Issue): Int { - val shouldReport: Boolean val messageResId = when (issue) { - is Issue.AppIntegrityCheckFailed -> { - shouldReport = false - RCore.string.crossAppLoginIntegrityError - } - is Issue.ErrorResponse -> { - shouldReport = issue.response.code !in 500..599 - RCore.string.anErrorHasOccurred - } - is Issue.NetworkIssue -> { - shouldReport = false - RCoreNetwork.string.connectionError - } - is Issue.OtherIssue -> { - shouldReport = true - RCore.string.anErrorHasOccurred - } + is Issue.AppIntegrityCheckFailed -> RCore.string.crossAppLoginIntegrityError + is Issue.ErrorResponse -> RCore.string.anErrorHasOccurred + is Issue.NetworkIssue -> RCoreNetwork.string.connectionError + is Issue.OtherIssue -> RCore.string.anErrorHasOccurred } val details = when (issue) { @@ -344,7 +332,7 @@ internal class CrossAppLoginFacadeImpl( else -> "" } val errorMessage = "Failed to derive token" - when (shouldReport) { + when (issue.shouldReport()) { true -> SentryLog.e(TAG, errorMessage, (issue as? Issue.OtherIssue)?.e) { scope -> scope.addErrorExtraAndTag(account, issue, details) } From 8f773d4fcff6d125b63ddd89840b4bdba9fbb319 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 09:41:24 +0200 Subject: [PATCH 07/56] chore: Move ensureRestorationIsHandled from impl to the sealed class --- .../infomaniak/core/auth/backup/RestoreFromBackupManager.kt | 5 ++++- .../core/auth/backup/RestoreFromBackupManagerImpl.kt | 5 ----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 6a7459bd8..391de22d6 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -19,12 +19,15 @@ package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.DerivedTokenGenerator import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first sealed class RestoreFromBackupManager { abstract val state: SharedFlow - abstract suspend fun ensureRestorationIsHandled() + suspend fun ensureRestorationIsHandled() { + if (State.Settled !in state.replayCache) state.first { it is State.Settled } + } sealed interface State { diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 44f378f58..be32a874a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -44,7 +44,6 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.shareIn import kotlinx.serialization.ExperimentalSerializationApi @@ -69,10 +68,6 @@ internal class RestoreFromBackupManagerImpl( emit(State.Settled) }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly) - override suspend fun ensureRestorationIsHandled() { - state.first { it is State.Settled } - } - private suspend fun FlowCollector.performRestorationHandlingIfNeeded() { val users = userDao.allUsers() val currentAndroidId = getAndroidId() From 811938a24164a65e3c6723b1447f511d91b7da65 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 09:42:44 +0200 Subject: [PATCH 08/56] chore: Add `shouldShowRestorationScreen` helper in RestoreFromBackupManager --- .../infomaniak/core/auth/backup/RestoreFromBackupManager.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 391de22d6..d75ec5601 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -18,8 +18,11 @@ package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.DerivedTokenGenerator +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map sealed class RestoreFromBackupManager { @@ -29,6 +32,8 @@ sealed class RestoreFromBackupManager { if (State.Settled !in state.replayCache) state.first { it is State.Settled } } + val shouldShowRestorationScreen: Flow = state.map { it != State.Settled }.distinctUntilChanged() + sealed interface State { data object Settled : State From b7d365c3c3eb86d2608cfed9b995f3d9da626e74 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 10:43:15 +0200 Subject: [PATCH 09/56] feat: Auto retry post-restoration token derivation if appropriate --- .../auth/DerivedTokenGenerator.IssueExtensions.kt | 11 +++++++++++ .../core/auth/backup/RestoreFromBackupManager.kt | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index 9130fe147..f1314a9e7 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.core.auth +import com.infomaniak.core.appintegrity.AppIntegrityIssue import com.infomaniak.core.auth.DerivedTokenGenerator.Issue fun Issue.shouldReport(): Boolean = when (this) { @@ -25,3 +26,13 @@ fun Issue.shouldReport(): Boolean = when (this) { is Issue.NetworkIssue -> false is Issue.OtherIssue -> true } + +internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { + is Issue.AppIntegrityCheckFailed -> when (details.issue) { + is AppIntegrityIssue.RetryLater, is AppIntegrityIssue.Internal -> true + is AppIntegrityIssue.DeviceIssue, is AppIntegrityIssue.DevError, is AppIntegrityIssue.SuspiciousError -> false + } + is Issue.ErrorResponse -> true + is Issue.NetworkIssue -> true + is Issue.OtherIssue -> false +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index d75ec5601..41d113692 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -18,6 +18,7 @@ package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.shouldRetryAutomatically import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -29,7 +30,14 @@ sealed class RestoreFromBackupManager { abstract val state: SharedFlow suspend fun ensureRestorationIsHandled() { - if (State.Settled !in state.replayCache) state.first { it is State.Settled } + when (val currentState = state.replayCache.firstOrNull()) { + State.Settled -> return + is State.RestoringFromBackupFailed if currentState.cause.shouldRetryAutomatically() -> { + currentState.retry() // Retry if appropriate for each new network call attempt. + } + else -> Unit + } + state.first { it is State.Settled } } val shouldShowRestorationScreen: Flow = state.map { it != State.Settled }.distinctUntilChanged() From 7d7e1757d8a90cf70f824d65cae00bf7e6763883 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:39:06 +0200 Subject: [PATCH 10/56] fix: Use a getter to get the up-to-date userDataCleanableList This could cause notification and device/app registration to not be done again after the user got logged out and re-logged in. --- .../infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt | 2 +- .../core/auth/PersistedCurrentUserAccountUtils.kt | 2 +- .../kotlin/com/infomaniak/core/auth/UserAccountUtils.kt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt index a5fd6bbed..37ad1d353 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.flowOf */ abstract class AbstractCurrentUserAccountUtils( appContext: Context, - userDataCleanableList: List = emptyList(), + userDataCleanableList: () -> List = { emptyList() }, userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), ) : UserAccountUtils(appContext, userDataCleanableList, userDatabase) { diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt index c78dcac57..9ae0c9ca0 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.Flow */ open class PersistedCurrentUserAccountUtils( appContext: Context, - userDataCleanableList: List = emptyList(), + userDataCleanableList: () -> List = { emptyList() }, userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), ) : AbstractCurrentUserAccountUtils(appContext, userDataCleanableList, userDatabase) { override val currentUserIdFlow: Flow = currentUserIdDao.getCurrentUserIdFlow() diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt index 9f3367709..a133c9039 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt @@ -35,7 +35,7 @@ import com.infomaniak.core.common.getAndroidId */ open class UserAccountUtils( appContext: Context, - private val userDataCleanableList: List = emptyList(), + private val userDataCleanableList: () -> List = { emptyList() }, override val userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), ) : BaseCredentialManager() { val users get() = userDao.allUsers @@ -45,7 +45,7 @@ open class UserAccountUtils( */ @CallSuper open suspend fun addUser(user: User) { - userDataCleanableList.forEach { it.resetForUser(user.id.toLong()) } + userDataCleanableList().forEach { it.resetForUser(user.id.toLong()) } userDatabase.useWriterConnection { it.immediateTransaction { userDao.insert(user) @@ -56,7 +56,7 @@ open class UserAccountUtils( @CallSuper open suspend fun removeUser(userId: Int) { - userDataCleanableList.forEach { it.resetForUser(userId.toLong()) } + userDataCleanableList().forEach { it.resetForUser(userId.toLong()) } userDao.deleteUserById(userId) } } From 379fb71855ace451aa86b13dc7e10ee4d2f76031 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:47:36 +0200 Subject: [PATCH 11/56] feat: Remove relevant users when the user gives up backup restoration --- .../infomaniak/core/auth/CredentialManager.kt | 11 +++++++++- .../infomaniak/core/auth/UserAccountUtils.kt | 5 +++++ .../auth/backup/RestoreFromBackupManager.kt | 2 ++ .../backup/RestoreFromBackupManagerImpl.kt | 20 +++++++++---------- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt index 39d357557..7bd1a9bbb 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt @@ -19,7 +19,10 @@ package com.infomaniak.core.auth import androidx.lifecycle.LiveData import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.AssociatedUserDataCleanable import com.infomaniak.core.login.ApiToken +import splitties.init.appCtx /** * CredentialManager: Adds a currentUserId and currentUser management layer to [BaseCredentialManager] @@ -29,7 +32,13 @@ import com.infomaniak.core.login.ApiToken * blocking methods which is fixed in the alternative classes. */ @Deprecated("It's recommended to use UserAccountUtils, AbstractCurrentUserAccountUtils or PersistedCurrentUserAccountUtils") -abstract class CredentialManager : BaseCredentialManager() { +abstract class CredentialManager( + userDataCleanableList: () -> List +) : UserAccountUtils( + appContext = appCtx, + userDataCleanableList = userDataCleanableList, + userDatabase = UserDatabase.instance, +) { abstract val currentUserId: Int abstract var currentUser: User? diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt index a133c9039..2c0f66b0e 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt @@ -22,6 +22,7 @@ import android.database.sqlite.SQLiteConstraintException import androidx.annotation.CallSuper import androidx.room.immediateTransaction import androidx.room.useWriterConnection +import com.infomaniak.core.auth.backup.RestoreFromBackupManager import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase @@ -40,6 +41,10 @@ open class UserAccountUtils( ) : BaseCredentialManager() { val users get() = userDao.allUsers + init { + RestoreFromBackupManager.instance.registerRemoveUser(::removeUser) + } + /** * @throws SQLiteConstraintException when adding a user with a primary key that already exists */ diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 41d113692..7fd7d6c56 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -42,6 +42,8 @@ sealed class RestoreFromBackupManager { val shouldShowRestorationScreen: Flow = state.map { it != State.Settled }.distinctUntilChanged() + abstract fun registerRemoveUser(removeUser: suspend (id: Int) -> Unit) + sealed interface State { data object Settled : State diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index be32a874a..5995558c2 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -68,6 +68,13 @@ internal class RestoreFromBackupManagerImpl( emit(State.Settled) }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly) + private val removeUserDeferred = CompletableDeferred Unit>() + + override fun registerRemoveUser(removeUser: suspend (id: Int) -> Unit) { + check(removeUserDeferred.isCompleted.not()) // Should not be called twice. + removeUserDeferred.complete(removeUser) + } + private suspend fun FlowCollector.performRestorationHandlingIfNeeded() { val users = userDao.allUsers() val currentAndroidId = getAndroidId() @@ -127,17 +134,8 @@ internal class RestoreFromBackupManagerImpl( val shouldRetry = shouldRetryAsync.await() val giveUp = !shouldRetry if (giveUp) { - userDb.useWriterConnection { - it.immediateTransaction { - TODO("removeUser from the db, and ensure associated data gets removed too") - //TODO: For kDrive, that would be removing: - // - MyKSuite data (MyKSuiteDataUtils.deleteData(…)) - // - everything called in AccountUtils.removeUser - // Maybe we need to give the ability to register a callback in the apps, - // as well as having a system to put data back in place when the app - // process starts with orphan user data (i.e/ user-tied data that is not in the User table) - } - } + val removeUser = removeUserDeferred.await() + issuesWithUser.forEach { (_, user) -> removeUser(user.id) } return } restoreAccounts(currentAndroidId = currentAndroidId, allUsers = allUsers) From 578056ce4fddff2467ba08e8cc97ada15a7427b3 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:51:49 +0200 Subject: [PATCH 12/56] docs: Update CrossAppLogin README.md --- CrossAppLogin/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CrossAppLogin/README.md b/CrossAppLogin/README.md index 40bf7e433..bd06078c7 100644 --- a/CrossAppLogin/README.md +++ b/CrossAppLogin/README.md @@ -83,6 +83,8 @@ open class MainApplication : Application() { } } +//NOTE: The 2 functions below are already present in the `UserAccountUtils` class, which should be used in new apps. + suspend fun addUser(user: User) { // Wherever the user adding code is. // ... val userId = user.id.toLong() @@ -90,8 +92,7 @@ suspend fun addUser(user: User) { // Wherever the user adding code is. // Save the user in the storage } -suspend fun removeUser(context: Context, user: User) { // Wherever the user removal code is. - val userId = user.id.toLong() +suspend fun removeUser(userId: Long) { // Wherever the user removal code is. MainApplication.userDataCleanableList.forEach { it.resetForUser(userId) } // Delete the user from storage // ... From 30e32369ac58feb3a178937fa97d39268388037d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 18:26:13 +0200 Subject: [PATCH 13/56] fix: Fix crashes and race condition --- .../core/auth/backup/RestoreFromBackupManager.kt | 2 +- .../auth/backup/RestoreFromBackupManagerImpl.kt | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index 7fd7d6c56..f077617f1 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -40,7 +40,7 @@ sealed class RestoreFromBackupManager { state.first { it is State.Settled } } - val shouldShowRestorationScreen: Flow = state.map { it != State.Settled }.distinctUntilChanged() + val shouldShowRestorationScreen: Flow by lazy { state.map { it != State.Settled }.distinctUntilChanged() } abstract fun registerRemoveUser(removeUser: suspend (id: Int) -> Unit) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 5995558c2..55a59bf0d 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -56,17 +56,19 @@ internal class RestoreFromBackupManagerImpl( private val userDb = UserDatabase.instance private val userDao = userDb.userDao() - private val derivedTokenGenerator: DerivedTokenGenerator = DerivedTokenGeneratorImpl( - coroutineScope = coroutineScope, - tokenRetrievalUrl = TOKEN_URL, - clientId = clientId, - userAgent = HttpUtils.getUserAgent, - ) + private val derivedTokenGenerator: DerivedTokenGenerator by lazy { + DerivedTokenGeneratorImpl( + coroutineScope = coroutineScope, + tokenRetrievalUrl = TOKEN_URL, + clientId = clientId, + userAgent = HttpUtils.getUserAgent, + ) + } override val state: SharedFlow = flow { performRestorationHandlingIfNeeded() emit(State.Settled) - }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly) + }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) private val removeUserDeferred = CompletableDeferred Unit>() From 9cb8e2737f5e49fcde08e3e0a8ddb5855a1fc86b Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 18:26:54 +0200 Subject: [PATCH 14/56] feat: Add barebones RestoringFromBackupFailedScreen --- Auth/build.gradle.kts | 4 ++ .../backup/RestoringFromBackupFailedScreen.kt | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index ea9f69978..6107d7839 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -45,7 +45,11 @@ dependencies { implementation(platform(core.compose.bom)) implementation(core.compose.ui) implementation(core.compose.runtime) + implementation(core.compose.ui) + implementation(core.compose.material3) implementation(core.activity.compose) + implementation(core.compose.ui.tooling.preview) + debugImplementation(core.compose.ui.tooling) implementation(core.appcompat) implementation(core.androidx.core.ktx) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt new file mode 100644 index 000000000..3c9ea7561 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -0,0 +1,62 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.infomaniak.core.auth.DerivedTokenGenerator + +@Composable +fun RestoringFromBackupFailedScreen( + state: RestoreFromBackupManager.State.RestoringFromBackupFailed, + modifier: Modifier = Modifier, +) { + Column( + modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom) + ) { + Text("Failed to restore your account", textAlign = TextAlign.Center) + Button(onClick = state.retry) { Text("Retry") } + TextButton(onClick = state.giveUp) { Text("Give up") } + Spacer(Modifier.padding(72.dp).navigationBarsPadding()) + } +} + +@Preview +@Composable +private fun RestoringFromBackupScreenPreviewScreen() { + RestoringFromBackupFailedScreen(RestoreFromBackupManager.State.RestoringFromBackupFailed( + cause = DerivedTokenGenerator.Issue.OtherIssue(Exception()), + retry = {}, + giveUp = {} + )) +} From d2977e9e68097cf9ddbbdfb4fe93adb5a1eb1074 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Jul 2026 13:24:10 +0200 Subject: [PATCH 15/56] feat: Move landscape UI of backup restoration failed to the end This avoids clashes with the splashscreen's logo --- .../backup/RestoringFromBackupFailedScreen.kt | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index 3c9ea7561..6561d32f1 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -18,11 +18,14 @@ package com.infomaniak.core.auth.backup import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -38,19 +41,53 @@ import com.infomaniak.core.auth.DerivedTokenGenerator fun RestoringFromBackupFailedScreen( state: RestoreFromBackupManager.State.RestoringFromBackupFailed, modifier: Modifier = Modifier, +) = AspectRatioFlow(modifier) { isLandscape -> + if (isLandscape) Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally)) { + Spacer(Modifier.weight(1f)) + RestorationFailed( + state = state, + modifier = Modifier.fillMaxHeight().weight(1f) + ) + } else { + RestorationFailed( + state = state, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun RestorationFailed( + state: RestoreFromBackupManager.State.RestoringFromBackupFailed, + modifier: Modifier = Modifier, ) { Column( - modifier.fillMaxSize(), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom) ) { Text("Failed to restore your account", textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) Button(onClick = state.retry) { Text("Retry") } TextButton(onClick = state.giveUp) { Text("Give up") } - Spacer(Modifier.padding(72.dp).navigationBarsPadding()) + Spacer(Modifier.height(72.dp).navigationBarsPadding()) + } +} + +@Composable +private fun AspectRatioFlow( + modifier: Modifier = Modifier, + content: @Composable (isLandscape: Boolean) -> Unit +) { + BoxWithConstraints(modifier = modifier) { + val isLandscape = maxWidth > maxHeight + content(isLandscape) } } +@Preview(device = "spec:parent=pixel_5,orientation=landscape") @Preview @Composable private fun RestoringFromBackupScreenPreviewScreen() { From fe6832e7f4753753d85c2a5f9f92a5f5a777a421 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Jul 2026 16:58:21 +0200 Subject: [PATCH 16/56] fix: Fix AccountUtils tests --- .../core/auth/AbstractCurrentUserAccountUtils.kt | 4 +++- .../core/auth/PersistedCurrentUserAccountUtils.kt | 4 +++- .../kotlin/com/infomaniak/core/auth/UserAccountUtils.kt | 3 ++- .../com/infomaniak/core/auth/AccountUtilsCommonTest.kt | 7 ++++++- .../java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt | 6 ++++++ .../core/auth/PersistedUserIdAccountUtilsTest.kt | 7 ++++++- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt index 37ad1d353..9fd17ea0e 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt @@ -21,6 +21,7 @@ import android.content.Context import android.database.sqlite.SQLiteConstraintException import androidx.annotation.CallSuper import androidx.room.withTransaction +import com.infomaniak.core.auth.backup.RestoreFromBackupManager import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.AssociatedUserDataCleanable @@ -40,7 +41,8 @@ abstract class AbstractCurrentUserAccountUtils( appContext: Context, userDataCleanableList: () -> List = { emptyList() }, userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), -) : UserAccountUtils(appContext, userDataCleanableList, userDatabase) { + restoreFromBackupManager: RestoreFromBackupManager = RestoreFromBackupManager.instance, +) : UserAccountUtils(appContext, userDataCleanableList, userDatabase, restoreFromBackupManager) { /** * If you need a live [User] instead of just its id, use [currentUserFlow] diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt index 9ae0c9ca0..6b0578dcd 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt @@ -18,6 +18,7 @@ package com.infomaniak.core.auth import android.content.Context +import com.infomaniak.core.auth.backup.RestoreFromBackupManager import com.infomaniak.core.auth.models.CurrentUserId import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.AssociatedUserDataCleanable @@ -30,7 +31,8 @@ open class PersistedCurrentUserAccountUtils( appContext: Context, userDataCleanableList: () -> List = { emptyList() }, userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), -) : AbstractCurrentUserAccountUtils(appContext, userDataCleanableList, userDatabase) { + restoreFromBackupManager: RestoreFromBackupManager = RestoreFromBackupManager.instance, +) : AbstractCurrentUserAccountUtils(appContext, userDataCleanableList, userDatabase, restoreFromBackupManager) { override val currentUserIdFlow: Flow = currentUserIdDao.getCurrentUserIdFlow() override suspend fun setCurrentUserId(userId: Int?) { diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt index 2c0f66b0e..e76c40363 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt @@ -38,11 +38,12 @@ open class UserAccountUtils( appContext: Context, private val userDataCleanableList: () -> List = { emptyList() }, override val userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), + restoreFromBackupManager: RestoreFromBackupManager = RestoreFromBackupManager.instance, ) : BaseCredentialManager() { val users get() = userDao.allUsers init { - RestoreFromBackupManager.instance.registerRemoveUser(::removeUser) + restoreFromBackupManager.registerRemoveUser(::removeUser) } /** diff --git a/Auth/src/test/java/com/infomaniak/core/auth/AccountUtilsCommonTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/AccountUtilsCommonTest.kt index 351d17242..ef4c28e79 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/AccountUtilsCommonTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/AccountUtilsCommonTest.kt @@ -18,6 +18,7 @@ package com.infomaniak.core.auth import android.database.sqlite.SQLiteConstraintException +import com.infomaniak.core.auth.backup.RestoreFromBackupManagerImpl import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase import kotlinx.coroutines.flow.first @@ -79,7 +80,11 @@ class AccountUtilsCommonTest : BaseAccountUtilsTest() { private inline fun withAccountUtils(block: UserAccountUtils.() -> Unit) { val userDatabase = UserDatabase.instantiateDataBase(context, true) - val persistedUserIdAccountUtils = object : UserAccountUtils(context, userDatabase = userDatabase) {} + val persistedUserIdAccountUtils = object : UserAccountUtils( + appContext = context, + userDatabase = userDatabase, + restoreFromBackupManager = RestoreFromBackupManagerImpl() + ) {} val result = runCatching { block(persistedUserIdAccountUtils) } diff --git a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt index 0243ffdc1..ebbbaed53 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt @@ -18,6 +18,7 @@ package com.infomaniak.core.auth import android.content.Context +import android.provider.Settings import androidx.test.core.app.ApplicationProvider import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.models.user.preferences.OrganizationPreference @@ -34,6 +35,11 @@ abstract class BaseAccountUtilsTest { init { context.injectAsAppCtx() + Settings.Secure.putString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + "test_android_id" + ) } protected fun userOf(id: Int): User { diff --git a/Auth/src/test/java/com/infomaniak/core/auth/PersistedUserIdAccountUtilsTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/PersistedUserIdAccountUtilsTest.kt index a70e1c982..0f08f2396 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/PersistedUserIdAccountUtilsTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/PersistedUserIdAccountUtilsTest.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.core.auth +import com.infomaniak.core.auth.backup.RestoreFromBackupManagerImpl import com.infomaniak.core.auth.room.UserDatabase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -218,7 +219,11 @@ class PersistedUserIdAccountUtilsTest : BaseAccountUtilsTest() { private inline fun withAccountUtils(block: PersistedCurrentUserAccountUtils.() -> Unit) { val userDatabase = UserDatabase.instantiateDataBase(context, true) - val persistedUserIdAccountUtils = object : PersistedCurrentUserAccountUtils(context, userDatabase = userDatabase) {} + val persistedUserIdAccountUtils = object : PersistedCurrentUserAccountUtils( + appContext = context, + userDatabase = userDatabase, + restoreFromBackupManager = RestoreFromBackupManagerImpl() + ) {} val result = runCatching { block(persistedUserIdAccountUtils) } From f476bba298bbe868bd98d96efbc8419171485771 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Jul 2026 16:51:19 +0200 Subject: [PATCH 17/56] fix: Ensure attestation tokens cannot be reused We are not going to allow reusing them. However, we will probably consider using the "standard" API (over the "classic" one), to allow generating many tokens for cheaper thanks to the shared costly operation. --- .../infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 55a59bf0d..27cd611c4 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -58,7 +58,6 @@ internal class RestoreFromBackupManagerImpl( private val derivedTokenGenerator: DerivedTokenGenerator by lazy { DerivedTokenGeneratorImpl( - coroutineScope = coroutineScope, tokenRetrievalUrl = TOKEN_URL, clientId = clientId, userAgent = HttpUtils.getUserAgent, From 7aff9d8c5722a4b9a4d1d62b3b6b7237429052c1 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 31 Jul 2026 09:29:36 +0200 Subject: [PATCH 18/56] feat: Add strings --- .../backup/RestoringFromBackupFailedScreen.kt | 9 +++++--- Auth/src/main/res/values-da/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-de/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-el/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-es/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-fi/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-fr/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-it/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-nb/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-nl/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-pl/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-pt/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values-sv/strings.xml | 21 +++++++++++++++++++ Auth/src/main/res/values/strings.xml | 21 +++++++++++++++++++ 14 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 Auth/src/main/res/values-da/strings.xml create mode 100644 Auth/src/main/res/values-de/strings.xml create mode 100644 Auth/src/main/res/values-el/strings.xml create mode 100644 Auth/src/main/res/values-es/strings.xml create mode 100644 Auth/src/main/res/values-fi/strings.xml create mode 100644 Auth/src/main/res/values-fr/strings.xml create mode 100644 Auth/src/main/res/values-it/strings.xml create mode 100644 Auth/src/main/res/values-nb/strings.xml create mode 100644 Auth/src/main/res/values-nl/strings.xml create mode 100644 Auth/src/main/res/values-pl/strings.xml create mode 100644 Auth/src/main/res/values-pt/strings.xml create mode 100644 Auth/src/main/res/values-sv/strings.xml create mode 100644 Auth/src/main/res/values/strings.xml diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index 6561d32f1..d8b0c26bf 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -32,10 +32,13 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.R +import com.infomaniak.core.common.R as RCore @Composable fun RestoringFromBackupFailedScreen( @@ -68,10 +71,10 @@ private fun RestorationFailed( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom) ) { - Text("Failed to restore your account", textAlign = TextAlign.Center) + Text(stringResource(R.string.accountRestoreFailedError), textAlign = TextAlign.Center) Spacer(Modifier.height(8.dp)) - Button(onClick = state.retry) { Text("Retry") } - TextButton(onClick = state.giveUp) { Text("Give up") } + Button(onClick = state.retry) { Text(stringResource(RCore.string.buttonRetry)) } + TextButton(onClick = state.giveUp) { Text(stringResource(R.string.buttonGiveUp)) } Spacer(Modifier.height(72.dp).navigationBarsPadding()) } } diff --git a/Auth/src/main/res/values-da/strings.xml b/Auth/src/main/res/values-da/strings.xml new file mode 100644 index 000000000..f27f9f6f9 --- /dev/null +++ b/Auth/src/main/res/values-da/strings.xml @@ -0,0 +1,21 @@ + + + Gendannelse af din konto mislykkedes + Opgiv + diff --git a/Auth/src/main/res/values-de/strings.xml b/Auth/src/main/res/values-de/strings.xml new file mode 100644 index 000000000..dacea12ee --- /dev/null +++ b/Auth/src/main/res/values-de/strings.xml @@ -0,0 +1,21 @@ + + + Wiederherstellung Ihres Kontos fehlgeschlagen + Aufgeben + diff --git a/Auth/src/main/res/values-el/strings.xml b/Auth/src/main/res/values-el/strings.xml new file mode 100644 index 000000000..4e40221e1 --- /dev/null +++ b/Auth/src/main/res/values-el/strings.xml @@ -0,0 +1,21 @@ + + + Αποτυχία επαναφοράς του λογαριασμού σας + Εγκατάλειψη + diff --git a/Auth/src/main/res/values-es/strings.xml b/Auth/src/main/res/values-es/strings.xml new file mode 100644 index 000000000..9861dceb2 --- /dev/null +++ b/Auth/src/main/res/values-es/strings.xml @@ -0,0 +1,21 @@ + + + Error al restaurar tu cuenta + Abandonar + diff --git a/Auth/src/main/res/values-fi/strings.xml b/Auth/src/main/res/values-fi/strings.xml new file mode 100644 index 000000000..c171657ac --- /dev/null +++ b/Auth/src/main/res/values-fi/strings.xml @@ -0,0 +1,21 @@ + + + Tilisi palauttaminen epäonnistui + Luovuta + diff --git a/Auth/src/main/res/values-fr/strings.xml b/Auth/src/main/res/values-fr/strings.xml new file mode 100644 index 000000000..fa8a5042a --- /dev/null +++ b/Auth/src/main/res/values-fr/strings.xml @@ -0,0 +1,21 @@ + + + Échec de la restauration de votre compte + Abandonner + diff --git a/Auth/src/main/res/values-it/strings.xml b/Auth/src/main/res/values-it/strings.xml new file mode 100644 index 000000000..ff09eaf3d --- /dev/null +++ b/Auth/src/main/res/values-it/strings.xml @@ -0,0 +1,21 @@ + + + Ripristino del tuo account non riuscito + Abbandona + diff --git a/Auth/src/main/res/values-nb/strings.xml b/Auth/src/main/res/values-nb/strings.xml new file mode 100644 index 000000000..77d049286 --- /dev/null +++ b/Auth/src/main/res/values-nb/strings.xml @@ -0,0 +1,21 @@ + + + Gjenoppretting av kontoen din mislyktes + Gi opp + diff --git a/Auth/src/main/res/values-nl/strings.xml b/Auth/src/main/res/values-nl/strings.xml new file mode 100644 index 000000000..97e21b87b --- /dev/null +++ b/Auth/src/main/res/values-nl/strings.xml @@ -0,0 +1,21 @@ + + + Herstellen van je account is mislukt + Opgeven + diff --git a/Auth/src/main/res/values-pl/strings.xml b/Auth/src/main/res/values-pl/strings.xml new file mode 100644 index 000000000..cb237fe2a --- /dev/null +++ b/Auth/src/main/res/values-pl/strings.xml @@ -0,0 +1,21 @@ + + + Nie udało się przywrócić Twojego konta + Porzuć + diff --git a/Auth/src/main/res/values-pt/strings.xml b/Auth/src/main/res/values-pt/strings.xml new file mode 100644 index 000000000..d12b56590 --- /dev/null +++ b/Auth/src/main/res/values-pt/strings.xml @@ -0,0 +1,21 @@ + + + Falha ao restaurar a sua conta + Abandonar + diff --git a/Auth/src/main/res/values-sv/strings.xml b/Auth/src/main/res/values-sv/strings.xml new file mode 100644 index 000000000..e74681329 --- /dev/null +++ b/Auth/src/main/res/values-sv/strings.xml @@ -0,0 +1,21 @@ + + + Det gick inte att återställa ditt konto + Ge upp + diff --git a/Auth/src/main/res/values/strings.xml b/Auth/src/main/res/values/strings.xml new file mode 100644 index 000000000..9627c4c7b --- /dev/null +++ b/Auth/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Failed to restore your account + Give up + From a288a061fc3272c317bb6e23c29e572d272de45f Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 31 Jul 2026 10:10:06 +0200 Subject: [PATCH 19/56] fix: Update UI --- .../backup/RestoringFromBackupFailedScreen.kt | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index d8b0c26bf..0f3ece0af 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -26,7 +26,9 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -47,11 +49,14 @@ fun RestoringFromBackupFailedScreen( ) = AspectRatioFlow(modifier) { isLandscape -> if (isLandscape) Row( modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally)) { + horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) + ) { Spacer(Modifier.weight(1f)) RestorationFailed( state = state, - modifier = Modifier.fillMaxHeight().weight(1f) + modifier = Modifier + .fillMaxHeight() + .weight(1f) ) } else { RestorationFailed( @@ -67,15 +72,23 @@ private fun RestorationFailed( modifier: Modifier = Modifier, ) { Column( - modifier = modifier, + modifier = modifier.padding(bottom = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom) + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom), ) { - Text(stringResource(R.string.accountRestoreFailedError), textAlign = TextAlign.Center) + Text( + stringResource(R.string.accountRestoreFailedError), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) Spacer(Modifier.height(8.dp)) Button(onClick = state.retry) { Text(stringResource(RCore.string.buttonRetry)) } TextButton(onClick = state.giveUp) { Text(stringResource(R.string.buttonGiveUp)) } - Spacer(Modifier.height(72.dp).navigationBarsPadding()) + Spacer( + Modifier + .height(72.dp) + .navigationBarsPadding() + ) } } @@ -94,9 +107,10 @@ private fun AspectRatioFlow( @Preview @Composable private fun RestoringFromBackupScreenPreviewScreen() { - RestoringFromBackupFailedScreen(RestoreFromBackupManager.State.RestoringFromBackupFailed( - cause = DerivedTokenGenerator.Issue.OtherIssue(Exception()), - retry = {}, - giveUp = {} - )) + RestoringFromBackupFailedScreen( + RestoreFromBackupManager.State.RestoringFromBackupFailed( + cause = DerivedTokenGenerator.Issue.OtherIssue(Exception()), + retry = {}, + giveUp = {} + )) } From 5153c98b5bfca9341cfc97dfc343ede173921355 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:14:05 +0200 Subject: [PATCH 20/56] chore: Extract nested when --- .../core/auth/DerivedTokenGenerator.IssueExtensions.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index f1314a9e7..6d4e77cdf 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -28,11 +28,13 @@ fun Issue.shouldReport(): Boolean = when (this) { } internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { - is Issue.AppIntegrityCheckFailed -> when (details.issue) { - is AppIntegrityIssue.RetryLater, is AppIntegrityIssue.Internal -> true - is AppIntegrityIssue.DeviceIssue, is AppIntegrityIssue.DevError, is AppIntegrityIssue.SuspiciousError -> false - } + is Issue.AppIntegrityCheckFailed -> shouldRetryAutomatically() is Issue.ErrorResponse -> true is Issue.NetworkIssue -> true is Issue.OtherIssue -> false } + +private fun Issue.AppIntegrityCheckFailed.shouldRetryAutomatically(): Boolean = when (details.issue) { + is AppIntegrityIssue.RetryLater, is AppIntegrityIssue.Internal -> true + is AppIntegrityIssue.DeviceIssue, is AppIntegrityIssue.DevError, is AppIntegrityIssue.SuspiciousError -> false +} From d981a32c85747b045d0d78402c2ae8fafd06e8ba Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:14:48 +0200 Subject: [PATCH 21/56] fix: Don't auto-retry if we received http 401 --- .../core/auth/DerivedTokenGenerator.IssueExtensions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index 6d4e77cdf..9fec6235a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -29,7 +29,7 @@ fun Issue.shouldReport(): Boolean = when (this) { internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { is Issue.AppIntegrityCheckFailed -> shouldRetryAutomatically() - is Issue.ErrorResponse -> true + is Issue.ErrorResponse -> this.response.code != 401 is Issue.NetworkIssue -> true is Issue.OtherIssue -> false } From 4aa19480241cdd82d55c24eff36f240c3620d294 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:21:17 +0200 Subject: [PATCH 22/56] chore: Add braces to if branch --- .../backup/RestoringFromBackupFailedScreen.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index 0f3ece0af..397f52691 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -47,17 +47,19 @@ fun RestoringFromBackupFailedScreen( state: RestoreFromBackupManager.State.RestoringFromBackupFailed, modifier: Modifier = Modifier, ) = AspectRatioFlow(modifier) { isLandscape -> - if (isLandscape) Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) - ) { - Spacer(Modifier.weight(1f)) - RestorationFailed( - state = state, - modifier = Modifier - .fillMaxHeight() - .weight(1f) - ) + if (isLandscape) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) + ) { + Spacer(Modifier.weight(1f)) + RestorationFailed( + state = state, + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) + } } else { RestorationFailed( state = state, From 8f4b1db5cca049dbd38a3d67f1e95944ac6b1478 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:29:45 +0200 Subject: [PATCH 23/56] chore: Add fast-path for not set up app --- .../infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 27cd611c4..8fa94f5ea 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -86,6 +86,8 @@ internal class RestoreFromBackupManagerImpl( currentAndroidId: String, allUsers: List, ) { + if (allUsers.isEmpty()) return // Fast-path for the app not set up yet case. + val usersToDeriveTokensFor: List = coroutineScope { allUsers.map { user -> async { From fcac944096d6c3c1f7bcbdc3bae802c70746b9db Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 11 Aug 2026 17:14:11 +0200 Subject: [PATCH 24/56] feat: Support the Authenticator app --- .../core/auth/BaseCredentialManager.kt | 4 +-- .../infomaniak/core/auth/TokenInterceptor.kt | 2 +- .../core/auth/TokenInterceptorListener.kt | 10 +++--- .../auth/backup/RestoreFromBackupManager.kt | 32 ++++++++++------- .../backup/RestoreFromBackupManagerImpl.kt | 36 ++++++++++++++----- .../com/infomaniak/core/auth/room/UserDao.kt | 5 +++ 6 files changed, 60 insertions(+), 29 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt index 18a6bc956..b154e22c3 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt @@ -22,8 +22,8 @@ import androidx.collection.ArrayMap import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase -import com.infomaniak.core.network.networking.HttpClientConfig import com.infomaniak.core.login.ApiToken +import com.infomaniak.core.network.networking.HttpClientConfig import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache @@ -125,7 +125,7 @@ abstract class BaseCredentialManager : UserExistenceChecker { private suspend fun getDefaultTokenInterceptorListener(userId: Int): TokenInterceptorListener { var user = userDatabase.userDao().findById(userId) - return object : TokenInterceptorListener { + return object : TokenInterceptorListener(dedicatedUserId = userId) { override suspend fun onRefreshTokenSuccess(apiToken: ApiToken) { setUserToken(user, apiToken) } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt index f98a18c5f..daa6be569 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt @@ -39,7 +39,7 @@ class TokenInterceptor( var request = chain.request() runBlocking(Dispatchers.Default) { - RestoreFromBackupManager.instance.ensureRestorationIsHandled() + RestoreFromBackupManager.instance.waitForRestorationCompletion(tokenInterceptorListener.dedicatedUserId) tokenInterceptorListener.getApiToken() }?.let { apiToken -> val authorization = request.header("Authorization") diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptorListener.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptorListener.kt index 0152a426f..16db3b871 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptorListener.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptorListener.kt @@ -27,11 +27,11 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.shareIn -interface TokenInterceptorListener { - suspend fun onRefreshTokenSuccess(apiToken: ApiToken) - suspend fun onRefreshTokenError() - suspend fun getApiToken(): ApiToken? - fun getCurrentUserId(): Int? +abstract class TokenInterceptorListener(val dedicatedUserId: Int?) { + abstract suspend fun onRefreshTokenSuccess(apiToken: ApiToken) + abstract suspend fun onRefreshTokenError() + abstract suspend fun getApiToken(): ApiToken? + abstract fun getCurrentUserId(): Int? /** * Maps a flow of user IDs to a shared flow of API tokens with caching. diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt index f077617f1..4fe71e42a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -18,27 +18,17 @@ package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.DerivedTokenGenerator -import com.infomaniak.core.auth.shouldRetryAutomatically import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import splitties.init.appCtx sealed class RestoreFromBackupManager { abstract val state: SharedFlow - suspend fun ensureRestorationIsHandled() { - when (val currentState = state.replayCache.firstOrNull()) { - State.Settled -> return - is State.RestoringFromBackupFailed if currentState.cause.shouldRetryAutomatically() -> { - currentState.retry() // Retry if appropriate for each new network call attempt. - } - else -> Unit - } - state.first { it is State.Settled } - } + abstract suspend fun waitForRestorationCompletion(targetUserId: Int?) val shouldShowRestorationScreen: Flow by lazy { state.map { it != State.Settled }.distinctUntilChanged() } @@ -60,7 +50,23 @@ sealed class RestoreFromBackupManager { ) : State } + enum class RestorationMode { + /** Handled by [RestoreFromBackupManager], with token derivation. */ + TokenDerivation, + /** Handled externally (with passkeys). */ + External, + } + companion object { - val instance: RestoreFromBackupManager = RestoreFromBackupManagerImpl() + val instance: RestoreFromBackupManager = RestoreFromBackupManagerImpl( + mode = if ("com.infomaniak.auth".let { packageWithPasskey -> + val currentAppId = appCtx.packageName + currentAppId == packageWithPasskey || currentAppId.startsWith("$packageWithPasskey.") + }) { + RestorationMode.External + } else { + RestorationMode.TokenDerivation + } + ) } } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 8fa94f5ea..b33956c1d 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -29,6 +29,7 @@ import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.auth.shouldReport +import com.infomaniak.core.auth.shouldRetryAutomatically import com.infomaniak.core.common.Xor import com.infomaniak.core.common.getAndroidId import com.infomaniak.core.login.ApiToken @@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.shareIn import kotlinx.serialization.ExperimentalSerializationApi @@ -51,11 +53,14 @@ import splitties.experimental.ExperimentalSplittiesApi internal class RestoreFromBackupManagerImpl( private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + private val mode: RestorationMode, ) : RestoreFromBackupManager() { private val userDb = UserDatabase.instance private val userDao = userDb.userDao() + private val removeUserDeferred = CompletableDeferred Unit>() + private val derivedTokenGenerator: DerivedTokenGenerator by lazy { DerivedTokenGeneratorImpl( tokenRetrievalUrl = TOKEN_URL, @@ -65,23 +70,38 @@ internal class RestoreFromBackupManagerImpl( } override val state: SharedFlow = flow { - performRestorationHandlingIfNeeded() + when (mode) { + RestorationMode.External -> { + emit(State.RestoringFromBackup) + waitForRestorationCompletion(null) + } + RestorationMode.TokenDerivation -> { + restoreAccounts(currentAndroidId = getAndroidId(), allUsers = userDao.allUsers()) + } + } emit(State.Settled) }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) - private val removeUserDeferred = CompletableDeferred Unit>() + override suspend fun waitForRestorationCompletion(targetUserId: Int?) { + when (val currentState = state.replayCache.firstOrNull()) { + State.Settled -> return + is State.RestoringFromBackupFailed if currentState.cause.shouldRetryAutomatically() -> { + currentState.retry() // Retry if appropriate for each new network call attempt. + } + else -> Unit + } + val currentAndroidId = getAndroidId() + when (targetUserId) { + null -> userDao.tokenDeviceBindings.first { list -> list.all { it.androidId == currentAndroidId } } + else -> userDao.tokenDeviceBinding(userId = targetUserId).first { it?.androidId == currentAndroidId } + } + } override fun registerRemoveUser(removeUser: suspend (id: Int) -> Unit) { check(removeUserDeferred.isCompleted.not()) // Should not be called twice. removeUserDeferred.complete(removeUser) } - private suspend fun FlowCollector.performRestorationHandlingIfNeeded() { - val users = userDao.allUsers() - val currentAndroidId = getAndroidId() - restoreAccounts(currentAndroidId = currentAndroidId, allUsers = users) - } - private tailrec suspend fun FlowCollector.restoreAccounts( currentAndroidId: String, allUsers: List, diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt index 65357daa1..b104e6a3a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt @@ -95,4 +95,9 @@ interface UserDao { @Query("SELECT * FROM TokenDeviceBinding WHERE userId=:userId") suspend fun getTokenDeviceBindingForUser(userId: Int): TokenDeviceBinding? + @get:Query("SELECT * FROM TokenDeviceBinding") + val tokenDeviceBindings: Flow> + + @Query("SELECT * FROM TokenDeviceBinding WHERE userId=:userId") + fun tokenDeviceBinding(userId: Int): Flow } From 102cbca34314a5f9b1800c0513971efa8e75e05c Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 11 Aug 2026 18:05:41 +0200 Subject: [PATCH 25/56] chore: Fix compilation by adding a default parameter value --- .../infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index b33956c1d..f8907630d 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -53,7 +53,7 @@ import splitties.experimental.ExperimentalSplittiesApi internal class RestoreFromBackupManagerImpl( private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), - private val mode: RestorationMode, + private val mode: RestorationMode = RestorationMode.TokenDerivation, ) : RestoreFromBackupManager() { private val userDb = UserDatabase.instance From 678b700b65fdaaf2c52ab55aaf7462966c73dbed Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 12 Aug 2026 09:30:55 +0200 Subject: [PATCH 26/56] chore: Ensure we don't auto-retry http 400 requests --- .../core/auth/DerivedTokenGenerator.IssueExtensions.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index 9fec6235a..6a50ec12c 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -29,7 +29,7 @@ fun Issue.shouldReport(): Boolean = when (this) { internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { is Issue.AppIntegrityCheckFailed -> shouldRetryAutomatically() - is Issue.ErrorResponse -> this.response.code != 401 + is Issue.ErrorResponse -> shouldRetryAutomatically() is Issue.NetworkIssue -> true is Issue.OtherIssue -> false } @@ -38,3 +38,8 @@ private fun Issue.AppIntegrityCheckFailed.shouldRetryAutomatically(): Boolean = is AppIntegrityIssue.RetryLater, is AppIntegrityIssue.Internal -> true is AppIntegrityIssue.DeviceIssue, is AppIntegrityIssue.DevError, is AppIntegrityIssue.SuspiciousError -> false } + +private fun Issue.ErrorResponse.shouldRetryAutomatically(): Boolean = when (response.code) { + 400, 401 -> false + else -> true +} From 8203de8d709901800a6d02c8f81d99969888e747 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 17 Aug 2026 13:02:10 +0200 Subject: [PATCH 27/56] docs: Add KDoc to explain why a parameter is needed --- .../core/auth/backup/RestoreFromBackupManagerImpl.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index f8907630d..abee8ff08 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -102,6 +102,9 @@ internal class RestoreFromBackupManagerImpl( removeUserDeferred.complete(removeUser) } + /** + * @param allUsers All users from the database. We compute this outside to avoid having to re-query the db on recursive calls. + */ private tailrec suspend fun FlowCollector.restoreAccounts( currentAndroidId: String, allUsers: List, From 6784b85b607e3658453a552c71ae8a2e8343c4fb Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 17 Aug 2026 14:35:29 +0200 Subject: [PATCH 28/56] refactor: Extract 2 functions This makes restoreAccounts more digest. --- .../backup/RestoreFromBackupManagerImpl.kt | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index abee8ff08..9226a2802 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -111,44 +111,10 @@ internal class RestoreFromBackupManagerImpl( ) { if (allUsers.isEmpty()) return // Fast-path for the app not set up yet case. - val usersToDeriveTokensFor: List = coroutineScope { - allUsers.map { user -> - async { - val currentBinding = userDao.getTokenDeviceBindingForUser(user.id) - when { - currentBinding == null -> { - userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) - null // Adding missing valid binding (post app update). - } - currentBinding.androidId == currentAndroidId -> null // Already valid. - else -> user // Device changed. Need to derive token. - } - } - } - }.awaitAll().filterNotNull() - - if (usersToDeriveTokensFor.isEmpty()) return - + val usersToDeriveTokensFor: List = getUsersThatNeedTokenRotation(currentAndroidId, allUsers).ifEmpty { return } emit(State.RestoringFromBackup) - val issuesWithUser = coroutineScope { - usersToDeriveTokensFor.map { user -> - async { - when (val result = attemptRestoringAccount(user)) { - is Xor.First -> userDb.useWriterConnection { - it.immediateTransaction { - userDao.update(user.copy(apiToken = result.value)) - userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) - } - null - } - is Xor.Second -> result.value to user - } - } - } - }.awaitAll().filterNotNull() - - if (issuesWithUser.isEmpty()) return + val issuesWithUser = attemptRestoringAccounts(usersToDeriveTokensFor, currentAndroidId).ifEmpty { return } val shouldRetryAsync = CompletableDeferred() val failedState = State.RestoringFromBackupFailed( @@ -167,6 +133,26 @@ internal class RestoreFromBackupManagerImpl( restoreAccounts(currentAndroidId = currentAndroidId, allUsers = allUsers) } + private suspend fun attemptRestoringAccounts( + usersToDeriveTokensFor: List, + currentAndroidId: String + ): List> = coroutineScope { + usersToDeriveTokensFor.map { user -> + async { + when (val result = attemptRestoringAccount(user)) { + is Xor.First -> userDb.useWriterConnection { + it.immediateTransaction { + userDao.update(user.copy(apiToken = result.value)) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + } + null + } + is Xor.Second -> result.value to user + } + } + } + }.awaitAll().filterNotNull() + private suspend fun attemptRestoringAccount(user: User): Xor { return derivedTokenGenerator.attemptDerivingOneOfTheseTokens(setOf(user.apiToken.accessToken)).also { result -> if (result !is Xor.Second) return@also @@ -182,6 +168,28 @@ internal class RestoreFromBackupManagerImpl( } } } + + /** + * @param allUsers All users from the database. We compute this outside to avoid having to re-query the db on recursive calls. + */ + private suspend fun getUsersThatNeedTokenRotation( + currentAndroidId: String, + allUsers: List + ): List = coroutineScope { + allUsers.map { user -> + async { + val currentBinding = userDao.getTokenDeviceBindingForUser(user.id) + when { + currentBinding == null -> { + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + null // Adding missing valid binding (post app update). + } + currentBinding.androidId == currentAndroidId -> null // Already valid. + else -> user // Device changed. Need to derive token. + } + } + } + }.awaitAll().filterNotNull() } private const val TAG = "RestoreFromBackupManagerImpl" From 46c9e4d6eef8f2f1d331ff4d4dd310f5eacbcc72 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 17 Aug 2026 15:44:57 +0200 Subject: [PATCH 29/56] chore: Use our Margin constants --- Auth/build.gradle.kts | 1 + .../core/auth/backup/RestoringFromBackupFailedScreen.kt | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 6107d7839..7d5e42f86 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(project(":AppIntegrity")) implementation(project(":Network")) implementation(project(":Sentry")) + implementation(project(":Ui:Compose:Margin")) implementation(platform(core.compose.bom)) implementation(core.compose.ui) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index 397f52691..adbf5a19c 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.infomaniak.core.auth.DerivedTokenGenerator import com.infomaniak.core.auth.R +import com.infomaniak.core.ui.compose.margin.Margin import com.infomaniak.core.common.R as RCore @Composable @@ -50,7 +51,7 @@ fun RestoringFromBackupFailedScreen( if (isLandscape) { Row( modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy(Margin.Medium, alignment = Alignment.CenterHorizontally) ) { Spacer(Modifier.weight(1f)) RestorationFailed( @@ -74,16 +75,16 @@ private fun RestorationFailed( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.padding(bottom = 48.dp), + modifier = modifier.padding(bottom = Margin.Giant), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom), + verticalArrangement = Arrangement.spacedBy(Margin.Mini, alignment = Alignment.Bottom), ) { Text( stringResource(R.string.accountRestoreFailedError), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onBackground ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(Margin.Mini)) Button(onClick = state.retry) { Text(stringResource(RCore.string.buttonRetry)) } TextButton(onClick = state.giveUp) { Text(stringResource(R.string.buttonGiveUp)) } Spacer( From efabd6e67c44761e08db0a847f6b0415707f4cb2 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 17 Aug 2026 15:59:02 +0200 Subject: [PATCH 30/56] chore: Rename composable to be more accurate --- .../core/auth/backup/RestoringFromBackupFailedScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt index adbf5a19c..40572b58f 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -47,7 +47,7 @@ import com.infomaniak.core.common.R as RCore fun RestoringFromBackupFailedScreen( state: RestoreFromBackupManager.State.RestoringFromBackupFailed, modifier: Modifier = Modifier, -) = AspectRatioFlow(modifier) { isLandscape -> +) = OrientationAwareContainer(modifier) { isLandscape -> if (isLandscape) { Row( modifier = Modifier.fillMaxSize(), @@ -96,7 +96,7 @@ private fun RestorationFailed( } @Composable -private fun AspectRatioFlow( +private fun OrientationAwareContainer( modifier: Modifier = Modifier, content: @Composable (isLandscape: Boolean) -> Unit ) { From b8c0830305f17f71d50194336d837742a086b274 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 8 Jul 2026 18:07:54 +0200 Subject: [PATCH 31/56] feat: Add WIP RestoreFromBackupManagerImpl --- Auth/build.gradle.kts | 1 + .../auth/backup/AccountsRestorationState.kt | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 7d5e42f86..e7b9dfd25 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(core.appcompat) implementation(core.androidx.core.ktx) implementation(core.kotlinx.serialization.json) + implementation(core.kotlinx.serialization.protobuf) implementation(core.gson) implementation(core.splitties.appctx) implementation(core.okhttp) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt new file mode 100644 index 000000000..576abfb5f --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt @@ -0,0 +1,31 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth.backup + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@ExperimentalSerializationApi +@Serializable +internal data class AccountsRestorationState( + @ProtoNumber(1) + val androidId: String, + @ProtoNumber(2) + val restoredAccountIds: Set, +) From b31717b1a7e9b139dce41b5dd0d3f523e2318a4d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 9 Jul 2026 18:57:25 +0200 Subject: [PATCH 32/56] chore: Store user token to device association in the db instead of a file --- Auth/build.gradle.kts | 1 - .../9.json | 2 +- .../auth/backup/AccountsRestorationState.kt | 31 ------------------- 3 files changed, 1 insertion(+), 33 deletions(-) delete mode 100644 Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index e7b9dfd25..7d5e42f86 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { implementation(core.appcompat) implementation(core.androidx.core.ktx) implementation(core.kotlinx.serialization.json) - implementation(core.kotlinx.serialization.protobuf) implementation(core.gson) implementation(core.splitties.appctx) implementation(core.okhttp) diff --git a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json index 7e212989b..cb2334363 100644 --- a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json +++ b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json @@ -173,4 +173,4 @@ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd2186f80b68b770c4f4081c454b43377')" ] } -} \ No newline at end of file +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt deleted file mode 100644 index 576abfb5f..000000000 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/AccountsRestorationState.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Infomaniak Core - Android - * Copyright (C) 2026 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 . - */ -package com.infomaniak.core.auth.backup - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.protobuf.ProtoNumber - -@ExperimentalSerializationApi -@Serializable -internal data class AccountsRestorationState( - @ProtoNumber(1) - val androidId: String, - @ProtoNumber(2) - val restoredAccountIds: Set, -) From fc18ac1ad0a2c75d4126dd4e959b83fa90a572f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:14:15 +0000 Subject: [PATCH 33/56] test: Add RestoreFromBackupManagerImpl tests for data transfer support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make RestoreFromBackupManagerImpl accept injectable userDatabase and tokenGenerator constructor parameters (defaulting to the existing production singletons) so the class can be exercised in isolation. Add RestoreFromBackupManagerImplTest covering the full state-machine: - No users → Settled immediately - Same-device users → Settled immediately (no derivation) - Pre-v9 users with missing binding → binding added, Settled - Multiple users, all missing bindings → bindings added, Settled - Transferred device with successful derivation → token & binding updated - Multiple transferred users, all succeed - Mixed (same-device + transferred) - Derivation failure → RestoringFromBackupFailed state emitted - Failure then retry (generator swapped to success) → Settled - Failure then give-up → failed user removed, Settled - Partial failure (one same-device, one transferred-and-failed) → only failed user removed --- .../backup/RestoreFromBackupManagerImpl.kt | 8 +- .../RestoreFromBackupManagerImplTest.kt | 321 ++++++++++++++++++ 2 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 9226a2802..9ed55b4e3 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -54,15 +54,17 @@ import splitties.experimental.ExperimentalSplittiesApi internal class RestoreFromBackupManagerImpl( private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), private val mode: RestorationMode = RestorationMode.TokenDerivation, + userDatabase: UserDatabase = UserDatabase.instance, + tokenGenerator: DerivedTokenGenerator? = null, ) : RestoreFromBackupManager() { - private val userDb = UserDatabase.instance - private val userDao = userDb.userDao() + private val userDb = userDatabase + private val userDao = userDatabase.userDao() private val removeUserDeferred = CompletableDeferred Unit>() private val derivedTokenGenerator: DerivedTokenGenerator by lazy { - DerivedTokenGeneratorImpl( + tokenGenerator ?: DerivedTokenGeneratorImpl( tokenRetrievalUrl = TOKEN_URL, clientId = clientId, userAgent = HttpUtils.getUserAgent, diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt new file mode 100644 index 000000000..1be8f8991 --- /dev/null +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -0,0 +1,321 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.auth.backup + +import com.infomaniak.core.auth.BaseAccountUtilsTest +import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.backup.RestoreFromBackupManager.State +import com.infomaniak.core.auth.models.TokenDeviceBinding +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.Xor +import com.infomaniak.core.login.ApiToken +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * Tests for [RestoreFromBackupManagerImpl], covering all paths in the restoration state machine: + * - Unchanged device (no restoration needed) + * - Pre-v9 missing binding (binding added, no restoration) + * - Transferred device with successful derivation + * - Multiple accounts (all same device / all transferred / mixed) + * - Partial failures + * - Retry after failure + * - Give-up after failure (failed users removed) + */ +class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { + + // Android ID set by BaseAccountUtilsTest as "test_android_id" + private val currentAndroidId = "test_android_id" + private val otherDeviceAndroidId = "other_device_id" + + // ------------------------------------------------------------------ helpers + + private fun TestScope.createManager( + userDatabase: UserDatabase, + tokenGenerator: DerivedTokenGenerator = FakeDerivedTokenGenerator(), + ) = RestoreFromBackupManagerImpl( + coroutineScope = backgroundScope, + userDatabase = userDatabase, + tokenGenerator = tokenGenerator, + ) + + private fun newToken(accessToken: String = "new_token"): ApiToken = + ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) + + private fun networkFailure(): Xor = + Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) + + /** Inserts a user plus a [TokenDeviceBinding] recording which device created the token. */ + private suspend fun UserDatabase.insertUserWithBinding(userId: Int, androidId: String) { + userDao().insert(userOf(userId)) + userDao().upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) + } + + /** Inserts a user with **no** [TokenDeviceBinding] – simulates a pre-v9 database row. */ + private suspend fun UserDatabase.insertUserWithoutBinding(userId: Int) { + userDao().insert(userOf(userId)) + } + + /** + * Collects [State] values emitted by [RestoreFromBackupManager.state] until [State.Settled], + * returning the full sequence (inclusive). Any [State.RestoringFromBackupFailed] encountered + * is forwarded to [onFailed] before the next state is awaited. + */ + private suspend fun RestoreFromBackupManager.collectStatesUntilSettled( + onFailed: (State.RestoringFromBackupFailed) -> Unit = {}, + ): List { + val states = mutableListOf() + state.first { s -> + states += s + if (s is State.RestoringFromBackupFailed) onFailed(s) + s is State.Settled + } + return states + } + + // --------------------------------------------------- no users + + @Test + fun noUsers_settlesImmediately() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + val manager = createManager(db) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.Settled), states) + db.close() + } + + // --------------------------------------------------- same device + + @Test + fun sameDevice_settlesImmediately() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + val manager = createManager(db) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.Settled), states) + db.close() + } + + @Test + fun multipleUsers_allSameDevice_settlesImmediately() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + db.insertUserWithBinding(userId = 2, androidId = currentAndroidId) + db.insertUserWithBinding(userId = 3, androidId = currentAndroidId) + val manager = createManager(db) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.Settled), states) + db.close() + } + + // --------------------------------------------------- pre-v9: missing binding + + @Test + fun missingBinding_addsBindingAndSettles() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithoutBinding(userId = 1) + val manager = createManager(db) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. + assertEquals(listOf(State.Settled), states) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + db.close() + } + + @Test + fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithoutBinding(userId = 1) + db.insertUserWithoutBinding(userId = 2) + val manager = createManager(db) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.Settled), states) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + db.close() + } + + // --------------------------------------------------- transferred device – success + + @Test + fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived_token")))) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) + assertEquals("derived_token", db.userDao().findById(1)?.apiToken?.accessToken) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + db.close() + } + + @Test + fun multipleUsers_allTransferred_allSucceed_settles() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) + val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + db.close() + } + + @Test + fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid + db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration + val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled() + + assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + db.close() + } + + // --------------------------------------------------- restoration failure + + @Test + fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled { failedState -> + failedState.giveUp() // unblock the flow so the test completes + } + + assertTrue(states.any { it is State.RestoringFromBackupFailed }) + val failed = states.filterIsInstance().first() + assertTrue(failed.cause is DerivedTokenGenerator.Issue.NetworkIssue) + db.close() + } + + // --------------------------------------------------- retry + + @Test + fun transferredDevice_derivationFails_thenRetry_succeeds() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val generator = FakeDerivedTokenGenerator(networkFailure()) + val manager = createManager(db, generator) + manager.registerRemoveUser { } + + val states = manager.collectStatesUntilSettled { failedState -> + // Switch to success on retry. + generator.result = Xor.First(newToken("derived_after_retry")) + failedState.retry() + } + + // Attempt 1 → failure → retry → success → Settled + assertTrue(states.any { it is State.RestoringFromBackup }) + assertEquals(1, states.count { it is State.RestoringFromBackupFailed }) + assertTrue(states.last() is State.Settled) + // Confirm the retry produced the new token and updated the binding. + assertEquals("derived_after_retry", db.userDao().findById(1)?.apiToken?.accessToken) + assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + db.close() + } + + // --------------------------------------------------- give-up + + @Test + fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) + + val removedUserIds = mutableListOf() + manager.registerRemoveUser { id -> removedUserIds += id } + + manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } + + assertEquals(listOf(1), removedUserIds) + db.close() + } + + @Test + fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = runTest { + val db = UserDatabase.instantiateDataBase(context, true) + // User 1 is on the current device: no restoration required, must not be removed. + db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + // User 2 was transferred: restoration will fail. + db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) + + val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) + + val removedUserIds = mutableListOf() + manager.registerRemoveUser { id -> removedUserIds += id } + + manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } + + // Only the user whose restoration failed must be removed. + assertEquals(listOf(2), removedUserIds) + db.close() + } + + // ------------------------------------------------------------------ fake + + private class FakeDerivedTokenGenerator( + var result: Xor = Xor.First( + ApiToken(accessToken = "new_token", tokenType = "Bearer", userId = 0, expiresIn = 3600) + ), + ) : DerivedTokenGenerator { + override suspend fun attemptDerivingOneOfTheseTokens( + tokensToTry: Set, + ): Xor = result + + override suspend fun isAppIntegrityGuaranteedToFail() = false + } +} From adeab2670ba069cabec44644c3c3ed6e411aac52 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 10:33:30 +0200 Subject: [PATCH 34/56] test: Allow DerivedTokenGenerator to be subclassed from tests --- .../kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt index 9921ae7bd..57b5191e5 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.kt @@ -22,7 +22,7 @@ import com.infomaniak.core.common.Xor import com.infomaniak.core.login.ApiToken import okhttp3.Response -sealed interface DerivedTokenGenerator { +interface DerivedTokenGenerator { suspend fun attemptDerivingOneOfTheseTokens(tokensToTry: Set): Xor suspend fun isAppIntegrityGuaranteedToFail(): Boolean From 1ea07dfe6f8dd19dd434bbcfb2f60ba0e2f1f76e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 10:34:36 +0200 Subject: [PATCH 35/56] test: Add timeout for tests so they fail sooner --- .../RestoreFromBackupManagerImplTest.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 1be8f8991..6443b1c51 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -15,6 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.BaseAccountUtilsTest @@ -31,6 +32,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException +import kotlin.time.Duration.Companion.seconds /** * Tests for [RestoreFromBackupManagerImpl], covering all paths in the restoration state machine: @@ -96,7 +98,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- no users @Test - fun noUsers_settlesImmediately() = runTest { + fun noUsers_settlesImmediately() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) val manager = createManager(db) manager.registerRemoveUser { } @@ -110,7 +112,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- same device @Test - fun sameDevice_settlesImmediately() = runTest { + fun sameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) val manager = createManager(db) @@ -123,7 +125,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allSameDevice_settlesImmediately() = runTest { + fun multipleUsers_allSameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) db.insertUserWithBinding(userId = 2, androidId = currentAndroidId) @@ -140,7 +142,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- pre-v9: missing binding @Test - fun missingBinding_addsBindingAndSettles() = runTest { + fun missingBinding_addsBindingAndSettles() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithoutBinding(userId = 1) val manager = createManager(db) @@ -155,7 +157,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = runTest { + fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithoutBinding(userId = 1) db.insertUserWithoutBinding(userId = 2) @@ -173,7 +175,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- transferred device – success @Test - fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = runTest { + fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -189,7 +191,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allTransferred_allSucceed_settles() = runTest { + fun multipleUsers_allTransferred_allSucceed_settles() = runTest(timeout = 2.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) @@ -205,7 +207,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = runTest { + fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration @@ -223,7 +225,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- restoration failure @Test - fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = runTest { + fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -243,7 +245,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- retry @Test - fun transferredDevice_derivationFails_thenRetry_succeeds() = runTest { + fun transferredDevice_derivationFails_thenRetry_succeeds() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -270,7 +272,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- give-up @Test - fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = runTest { + fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -286,7 +288,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = runTest { + fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = runTest(timeout = .1.seconds) { val db = UserDatabase.instantiateDataBase(context, true) // User 1 is on the current device: no restoration required, must not be removed. db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) From 5cc7d3c29452a335c04dddd74796a89233c64ac7 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 11:06:23 +0200 Subject: [PATCH 36/56] test: Use kotest assertions for better readability --- Auth/build.gradle.kts | 1 + .../RestoreFromBackupManagerImplTest.kt | 57 ++++++++++--------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 7d5e42f86..308fa1e22 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -69,5 +69,6 @@ dependencies { testImplementation(core.junit) testImplementation(core.androidx.test.core) testImplementation(core.kotlinx.coroutines.test) + testImplementation(core.kotest.assertions) testImplementation(core.robolectric) } diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 6443b1c51..cf0f5688f 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -25,11 +25,12 @@ import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.Xor import com.infomaniak.core.login.ApiToken +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException import kotlin.time.Duration.Companion.seconds @@ -105,7 +106,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.Settled), states) + states shouldBe listOf(State.Settled) db.close() } @@ -120,7 +121,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.Settled), states) + states shouldBe listOf(State.Settled) db.close() } @@ -135,7 +136,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.Settled), states) + states shouldBe listOf(State.Settled) db.close() } @@ -151,8 +152,8 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. - assertEquals(listOf(State.Settled), states) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + states shouldBe listOf(State.Settled) + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.close() } @@ -166,9 +167,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.Settled), states) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + states shouldBe listOf(State.Settled) + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId db.close() } @@ -184,9 +185,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) - assertEquals("derived_token", db.userDao().findById(1)?.apiToken?.accessToken) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_token" + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.close() } @@ -200,9 +201,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId db.close() } @@ -216,9 +217,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() - assertEquals(listOf(State.RestoringFromBackup, State.Settled), states) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(2)?.androidId) + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId db.close() } @@ -236,9 +237,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { failedState.giveUp() // unblock the flow so the test completes } - assertTrue(states.any { it is State.RestoringFromBackupFailed }) + states.any { it is State.RestoringFromBackupFailed }.shouldBeTrue() val failed = states.filterIsInstance().first() - assertTrue(failed.cause is DerivedTokenGenerator.Issue.NetworkIssue) + failed.cause.shouldBeInstanceOf() db.close() } @@ -260,12 +261,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } // Attempt 1 → failure → retry → success → Settled - assertTrue(states.any { it is State.RestoringFromBackup }) - assertEquals(1, states.count { it is State.RestoringFromBackupFailed }) - assertTrue(states.last() is State.Settled) + states.any { it is State.RestoringFromBackup }.shouldBeTrue() + states.count { it is State.RestoringFromBackupFailed } shouldBe 1 + states.last().shouldBeInstanceOf() // Confirm the retry produced the new token and updated the binding. - assertEquals("derived_after_retry", db.userDao().findById(1)?.apiToken?.accessToken) - assertEquals(currentAndroidId, db.userDao().getTokenDeviceBindingForUser(1)?.androidId) + db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_after_retry" + db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.close() } @@ -283,7 +284,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } - assertEquals(listOf(1), removedUserIds) + removedUserIds shouldBe listOf(1) db.close() } @@ -303,7 +304,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } // Only the user whose restoration failed must be removed. - assertEquals(listOf(2), removedUserIds) + removedUserIds shouldBe listOf(2) db.close() } From 21c16f83fbee7cdf1c42844da8b8f0a4ab7d9bb8 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 11:25:25 +0200 Subject: [PATCH 37/56] test: Move helpers at the end of the file --- .../RestoreFromBackupManagerImplTest.kt | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index cf0f5688f..1935b6e4d 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -51,51 +51,6 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { private val currentAndroidId = "test_android_id" private val otherDeviceAndroidId = "other_device_id" - // ------------------------------------------------------------------ helpers - - private fun TestScope.createManager( - userDatabase: UserDatabase, - tokenGenerator: DerivedTokenGenerator = FakeDerivedTokenGenerator(), - ) = RestoreFromBackupManagerImpl( - coroutineScope = backgroundScope, - userDatabase = userDatabase, - tokenGenerator = tokenGenerator, - ) - - private fun newToken(accessToken: String = "new_token"): ApiToken = - ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) - - private fun networkFailure(): Xor = - Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) - - /** Inserts a user plus a [TokenDeviceBinding] recording which device created the token. */ - private suspend fun UserDatabase.insertUserWithBinding(userId: Int, androidId: String) { - userDao().insert(userOf(userId)) - userDao().upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) - } - - /** Inserts a user with **no** [TokenDeviceBinding] – simulates a pre-v9 database row. */ - private suspend fun UserDatabase.insertUserWithoutBinding(userId: Int) { - userDao().insert(userOf(userId)) - } - - /** - * Collects [State] values emitted by [RestoreFromBackupManager.state] until [State.Settled], - * returning the full sequence (inclusive). Any [State.RestoringFromBackupFailed] encountered - * is forwarded to [onFailed] before the next state is awaited. - */ - private suspend fun RestoreFromBackupManager.collectStatesUntilSettled( - onFailed: (State.RestoringFromBackupFailed) -> Unit = {}, - ): List { - val states = mutableListOf() - state.first { s -> - states += s - if (s is State.RestoringFromBackupFailed) onFailed(s) - s is State.Settled - } - return states - } - // --------------------------------------------------- no users @Test @@ -308,7 +263,52 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { db.close() } - // ------------------------------------------------------------------ fake + // ------------------------------------------------------------------ helpers + + private fun testUserDb() = UserDatabase.instantiateDataBase(context, inMemory = true) + + private fun TestScope.createManager( + userDatabase: UserDatabase, + tokenGenerator: DerivedTokenGenerator = FakeDerivedTokenGenerator(), + ) = RestoreFromBackupManagerImpl( + coroutineScope = backgroundScope, + userDatabase = userDatabase, + tokenGenerator = tokenGenerator, + ) + + private fun newToken(accessToken: String = "new_token"): ApiToken = + ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) + + private fun networkFailure(): Xor = + Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) + + /** Inserts a user plus a [TokenDeviceBinding] recording which device created the token. */ + private suspend fun UserDatabase.insertUserWithBinding(userId: Int, androidId: String) { + userDao().insert(userOf(userId)) + userDao().upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) + } + + /** Inserts a user with **no** [TokenDeviceBinding] – simulates a pre-v9 database row. */ + private suspend fun UserDatabase.insertUserWithoutBinding(userId: Int) { + userDao().insert(userOf(userId)) + } + + /** + * Collects [State] values emitted by [RestoreFromBackupManager.state] until [State.Settled], + * returning the full sequence (inclusive). Any [State.RestoringFromBackupFailed] encountered + * is forwarded to [onFailed] before the next state is awaited. + */ + private suspend fun RestoreFromBackupManager.collectStatesUntilSettled( + onFailed: (State.RestoringFromBackupFailed) -> Unit = {}, + ): List { + val states = mutableListOf() + state.first { s -> + states += s + if (s is State.RestoringFromBackupFailed) onFailed(s) + s is State.Settled + } + return states + } private class FakeDerivedTokenGenerator( var result: Xor = Xor.First( From e9dea3bf9121b33f0021892370ce8e8a378cfb41 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 11:25:59 +0200 Subject: [PATCH 38/56] test: Use new testUserDb helper for conciseness --- .../RestoreFromBackupManagerImplTest.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 1935b6e4d..4b4b599dc 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -55,7 +55,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun noUsers_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() val manager = createManager(db) manager.registerRemoveUser { } @@ -69,7 +69,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun sameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) val manager = createManager(db) manager.registerRemoveUser { } @@ -82,7 +82,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun multipleUsers_allSameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) db.insertUserWithBinding(userId = 2, androidId = currentAndroidId) db.insertUserWithBinding(userId = 3, androidId = currentAndroidId) @@ -99,7 +99,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun missingBinding_addsBindingAndSettles() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithoutBinding(userId = 1) val manager = createManager(db) manager.registerRemoveUser { } @@ -114,7 +114,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithoutBinding(userId = 1) db.insertUserWithoutBinding(userId = 2) val manager = createManager(db) @@ -132,7 +132,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived_token")))) @@ -148,7 +148,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun multipleUsers_allTransferred_allSucceed_settles() = runTest(timeout = 2.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) @@ -164,7 +164,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) @@ -182,7 +182,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) @@ -202,7 +202,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun transferredDevice_derivationFails_thenRetry_succeeds() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val generator = FakeDerivedTokenGenerator(networkFailure()) @@ -229,7 +229,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) @@ -245,7 +245,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { @Test fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = runTest(timeout = .1.seconds) { - val db = UserDatabase.instantiateDataBase(context, true) + val db = testUserDb() // User 1 is on the current device: no restoration required, must not be removed. db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // User 2 was transferred: restoration will fail. From 75e500aa5c67b77778a4b46478469ee5237b00a5 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 11:26:54 +0200 Subject: [PATCH 39/56] test: Replace reference comment by constant --- .../java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt | 6 +++++- .../core/auth/backup/RestoreFromBackupManagerImplTest.kt | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt index ebbbaed53..20bb8b651 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt @@ -31,6 +31,10 @@ import splitties.init.injectAsAppCtx @RunWith(RobolectricTestRunner::class) abstract class BaseAccountUtilsTest { + companion object { + const val testAndroidId = "test_android_id" + } + protected var context: Context = ApplicationProvider.getApplicationContext() init { @@ -38,7 +42,7 @@ abstract class BaseAccountUtilsTest { Settings.Secure.putString( context.contentResolver, Settings.Secure.ANDROID_ID, - "test_android_id" + testAndroidId ) } diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 4b4b599dc..1adc188f5 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -47,8 +47,7 @@ import kotlin.time.Duration.Companion.seconds */ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { - // Android ID set by BaseAccountUtilsTest as "test_android_id" - private val currentAndroidId = "test_android_id" + private val currentAndroidId: String = testAndroidId private val otherDeviceAndroidId = "other_device_id" // --------------------------------------------------- no users @@ -294,7 +293,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } /** - * Collects [State] values emitted by [RestoreFromBackupManager.state] until [State.Settled], + * Collects [State] values emitted by [state] until [State.Settled], * returning the full sequence (inclusive). Any [State.RestoringFromBackupFailed] encountered * is forwarded to [onFailed] before the next state is awaited. */ From da5b142509528bf1bdedd9117ecdc3c07e6c818b Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 11:37:27 +0200 Subject: [PATCH 40/56] test: Add extra test helper for more conciseness --- .../RestoreFromBackupManagerImplTest.kt | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 1adc188f5..1ac5875cb 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import java.io.IOException +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** @@ -53,22 +54,19 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- no users @Test - fun noUsers_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun noUsers_settlesImmediately() = test { db -> val manager = createManager(db) manager.registerRemoveUser { } val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) - db.close() } // --------------------------------------------------- same device @Test - fun sameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun sameDevice_settlesImmediately() = test { db -> db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) val manager = createManager(db) manager.registerRemoveUser { } @@ -76,12 +74,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) - db.close() } @Test - fun multipleUsers_allSameDevice_settlesImmediately() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun multipleUsers_allSameDevice_settlesImmediately() = test { db -> db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) db.insertUserWithBinding(userId = 2, androidId = currentAndroidId) db.insertUserWithBinding(userId = 3, androidId = currentAndroidId) @@ -91,14 +87,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) - db.close() } // --------------------------------------------------- pre-v9: missing binding @Test - fun missingBinding_addsBindingAndSettles() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun missingBinding_addsBindingAndSettles() = test { db -> db.insertUserWithoutBinding(userId = 1) val manager = createManager(db) manager.registerRemoveUser { } @@ -108,12 +102,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. states shouldBe listOf(State.Settled) db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.close() } @Test - fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = test { db -> db.insertUserWithoutBinding(userId = 1) db.insertUserWithoutBinding(userId = 2) val manager = createManager(db) @@ -124,14 +116,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states shouldBe listOf(State.Settled) db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId - db.close() } // --------------------------------------------------- transferred device – success @Test - fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = test { db -> db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived_token")))) @@ -142,12 +132,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states shouldBe listOf(State.RestoringFromBackup, State.Settled) db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_token" db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.close() } @Test - fun multipleUsers_allTransferred_allSucceed_settles() = runTest(timeout = 2.seconds) { - val db = testUserDb() + fun multipleUsers_allTransferred_allSucceed_settles() = test(timeout = 2.seconds) { db -> db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) @@ -158,12 +146,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states shouldBe listOf(State.RestoringFromBackup, State.Settled) db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId - db.close() } @Test - fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = test { db -> db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) @@ -174,14 +160,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states shouldBe listOf(State.RestoringFromBackup, State.Settled) db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId - db.close() } // --------------------------------------------------- restoration failure @Test - fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = test { db -> db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) @@ -194,14 +178,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states.any { it is State.RestoringFromBackupFailed }.shouldBeTrue() val failed = states.filterIsInstance().first() failed.cause.shouldBeInstanceOf() - db.close() } // --------------------------------------------------- retry @Test - fun transferredDevice_derivationFails_thenRetry_succeeds() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun transferredDevice_derivationFails_thenRetry_succeeds() = test { db -> db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val generator = FakeDerivedTokenGenerator(networkFailure()) @@ -221,14 +203,12 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // Confirm the retry produced the new token and updated the binding. db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_after_retry" db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.close() } // --------------------------------------------------- give-up @Test - fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = test { db -> db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) @@ -239,12 +219,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } removedUserIds shouldBe listOf(1) - db.close() } @Test - fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = runTest(timeout = .1.seconds) { - val db = testUserDb() + fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = test { db -> // User 1 is on the current device: no restoration required, must not be removed. db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // User 2 was transferred: restoration will fail. @@ -259,12 +237,20 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // Only the user whose restoration failed must be removed. removedUserIds shouldBe listOf(2) - db.close() } // ------------------------------------------------------------------ helpers - private fun testUserDb() = UserDatabase.instantiateDataBase(context, inMemory = true) + private fun test(timeout: Duration = .1.seconds, block: suspend TestScope.(userDb: UserDatabase) -> Unit) { + runTest(timeout = timeout) { + val db = UserDatabase.instantiateDataBase(context, inMemory = true) + try { + block(db) + } finally { + db.close() + } + } + } private fun TestScope.createManager( userDatabase: UserDatabase, From 85ac25dce10e7aa933188d0fbf6325f76a2ba212 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 15:01:49 +0200 Subject: [PATCH 41/56] test: Add custom test scope for more conciseness --- .../RestoreFromBackupManagerImplTest.kt | 206 +++++++++--------- 1 file changed, 101 insertions(+), 105 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 1ac5875cb..7b17e37f5 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -22,10 +22,13 @@ import com.infomaniak.core.auth.BaseAccountUtilsTest import com.infomaniak.core.auth.DerivedTokenGenerator import com.infomaniak.core.auth.backup.RestoreFromBackupManager.State import com.infomaniak.core.auth.models.TokenDeviceBinding +import com.infomaniak.core.auth.room.UserDao import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.Xor import com.infomaniak.core.login.ApiToken import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.flow.first @@ -54,10 +57,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- no users @Test - fun noUsers_settlesImmediately() = test { db -> - val manager = createManager(db) - manager.registerRemoveUser { } - + fun noUsers_settlesImmediately() = test { _ -> val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) @@ -66,10 +66,8 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- same device @Test - fun sameDevice_settlesImmediately() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) - val manager = createManager(db) - manager.registerRemoveUser { } + fun sameDevice_settlesImmediately() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) val states = manager.collectStatesUntilSettled() @@ -77,12 +75,10 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allSameDevice_settlesImmediately() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) - db.insertUserWithBinding(userId = 2, androidId = currentAndroidId) - db.insertUserWithBinding(userId = 3, androidId = currentAndroidId) - val manager = createManager(db) - manager.registerRemoveUser { } + fun multipleUsers_allSameDevice_settlesImmediately() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + userDao.insertUserWithBinding(userId = 2, androidId = currentAndroidId) + userDao.insertUserWithBinding(userId = 3, androidId = currentAndroidId) val states = manager.collectStatesUntilSettled() @@ -92,84 +88,72 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- pre-v9: missing binding @Test - fun missingBinding_addsBindingAndSettles() = test { db -> - db.insertUserWithoutBinding(userId = 1) - val manager = createManager(db) - manager.registerRemoveUser { } + fun missingBinding_addsBindingAndSettles() = test { userDao -> + userDao.insertUserWithoutBinding(userId = 1) val states = manager.collectStatesUntilSettled() // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. states shouldBe listOf(State.Settled) - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId } @Test - fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = test { db -> - db.insertUserWithoutBinding(userId = 1) - db.insertUserWithoutBinding(userId = 2) - val manager = createManager(db) - manager.registerRemoveUser { } + fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = test { userDao -> + userDao.insertUserWithoutBinding(userId = 1) + userDao.insertUserWithoutBinding(userId = 2) val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId } // --------------------------------------------------- transferred device – success @Test - fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) - - val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived_token")))) - manager.registerRemoveUser { } + fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.RestoringFromBackup, State.Settled) - db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_token" - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.findById(1)?.apiToken?.accessToken shouldBe "derived_token" + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId } @Test - fun multipleUsers_allTransferred_allSucceed_settles() = test(timeout = 2.seconds) { db -> - db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) - db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) - val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) - manager.registerRemoveUser { } + fun multipleUsers_allTransferred_allSucceed_settles() = test(timeout = 2.seconds) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.RestoringFromBackup, State.Settled) - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId } @Test - fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid - db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration - val manager = createManager(db, FakeDerivedTokenGenerator(Xor.First(newToken("derived")))) - manager.registerRemoveUser { } + fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.RestoringFromBackup, State.Settled) - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId - db.userDao().getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId } // --------------------------------------------------- restoration failure @Test - fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) - - val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) - manager.registerRemoveUser { } + fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled { failedState -> failedState.giveUp() // unblock the flow so the test completes @@ -183,16 +167,14 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- retry @Test - fun transferredDevice_derivationFails_thenRetry_succeeds() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) - - val generator = FakeDerivedTokenGenerator(networkFailure()) - val manager = createManager(db, generator) - manager.registerRemoveUser { } + fun transferredDevice_derivationFails_thenRetry_succeeds() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled { failedState -> // Switch to success on retry. - generator.result = Xor.First(newToken("derived_after_retry")) + derivedTokenGenerator.result = Xor.First(newToken("derived_after_retry")) failedState.retry() } @@ -201,81 +183,72 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states.count { it is State.RestoringFromBackupFailed } shouldBe 1 states.last().shouldBeInstanceOf() // Confirm the retry produced the new token and updated the binding. - db.userDao().findById(1)?.apiToken?.accessToken shouldBe "derived_after_retry" - db.userDao().getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.findById(1)?.apiToken?.accessToken shouldBe "derived_after_retry" + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId } // --------------------------------------------------- give-up @Test - fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = test { db -> - db.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) - - val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) - - val removedUserIds = mutableListOf() - manager.registerRemoveUser { id -> removedUserIds += id } - - manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } + fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + manager.collectStatesUntilSettled { failedState -> + userDao.findById(1).shouldNotBeNull() + failedState.giveUp() + } - removedUserIds shouldBe listOf(1) + userDao.findById(1).shouldBeNull() } @Test - fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = test { db -> + fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> // User 1 is on the current device: no restoration required, must not be removed. - db.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // User 2 was transferred: restoration will fail. - db.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) - - val manager = createManager(db, FakeDerivedTokenGenerator(networkFailure())) - - val removedUserIds = mutableListOf() - manager.registerRemoveUser { id -> removedUserIds += id } + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } // Only the user whose restoration failed must be removed. - removedUserIds shouldBe listOf(2) + userDao.findById(2) shouldBe null } // ------------------------------------------------------------------ helpers - private fun test(timeout: Duration = .1.seconds, block: suspend TestScope.(userDb: UserDatabase) -> Unit) { + private fun test( + timeout: Duration = .1.seconds, + initialTokenDerivationResult: Xor = Xor.First(newToken("derived_token")), + block: suspend RestoreFromBackupTestScope.(userDao: UserDao) -> Unit + ) { runTest(timeout = timeout) { - val db = UserDatabase.instantiateDataBase(context, inMemory = true) + val userDatabase = UserDatabase.instantiateDataBase(context, inMemory = true) + val restoreFromBackupTestScope = RestoreFromBackupTestScope( + testScope = this, + userDatabase = userDatabase, + initialTokenDerivationResult = initialTokenDerivationResult, + ) try { - block(db) + restoreFromBackupTestScope.block(userDatabase.userDao()) } finally { - db.close() + userDatabase.close() } } } - private fun TestScope.createManager( - userDatabase: UserDatabase, - tokenGenerator: DerivedTokenGenerator = FakeDerivedTokenGenerator(), - ) = RestoreFromBackupManagerImpl( - coroutineScope = backgroundScope, - userDatabase = userDatabase, - tokenGenerator = tokenGenerator, - ) - - private fun newToken(accessToken: String = "new_token"): ApiToken = - ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) - - private fun networkFailure(): Xor = - Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) - /** Inserts a user plus a [TokenDeviceBinding] recording which device created the token. */ - private suspend fun UserDatabase.insertUserWithBinding(userId: Int, androidId: String) { - userDao().insert(userOf(userId)) - userDao().upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) + private suspend fun UserDao.insertUserWithBinding(userId: Int, androidId: String) { + insert(userOf(userId)) + upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) } /** Inserts a user with **no** [TokenDeviceBinding] – simulates a pre-v9 database row. */ - private suspend fun UserDatabase.insertUserWithoutBinding(userId: Int) { - userDao().insert(userOf(userId)) + private suspend fun UserDao.insertUserWithoutBinding(userId: Int) { + insert(userOf(userId)) } /** @@ -284,7 +257,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { * is forwarded to [onFailed] before the next state is awaited. */ private suspend fun RestoreFromBackupManager.collectStatesUntilSettled( - onFailed: (State.RestoringFromBackupFailed) -> Unit = {}, + onFailed: suspend (State.RestoringFromBackupFailed) -> Unit = {}, ): List { val states = mutableListOf() state.first { s -> @@ -306,4 +279,27 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { override suspend fun isAppIntegrityGuaranteedToFail() = false } + + private class RestoreFromBackupTestScope( + testScope: TestScope, + userDatabase: UserDatabase, + initialTokenDerivationResult: Xor, + ) { + val derivedTokenGenerator = FakeDerivedTokenGenerator(initialTokenDerivationResult) + val manager: RestoreFromBackupManager by lazy { + RestoreFromBackupManagerImpl( + coroutineScope = testScope.backgroundScope, + userDatabase = userDatabase, + tokenGenerator = derivedTokenGenerator + ).also { + it.registerRemoveUser { id -> userDatabase.userDao().deleteUserById(id) } + } + } + } } + +private fun newToken(accessToken: String = "new_token"): ApiToken = + ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) + +private fun networkFailure(): Xor = + Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) From 1db50ce9cb4eef5ca7f066b00e66ff46513d653a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 15:09:39 +0200 Subject: [PATCH 42/56] test: Add extra assertions in RestoreFromBackupManagerImplTest --- .../backup/RestoreFromBackupManagerImplTest.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index 7b17e37f5..ecc58d16a 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -72,6 +72,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) + userDao.findById(1).shouldNotBeNull() } @Test @@ -83,6 +84,9 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldNotBeNull() + userDao.findById(3).shouldNotBeNull() } // --------------------------------------------------- pre-v9: missing binding @@ -96,6 +100,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. states shouldBe listOf(State.Settled) userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.findById(1).shouldNotBeNull() } @Test @@ -108,6 +113,8 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { states shouldBe listOf(State.Settled) userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldNotBeNull() } // --------------------------------------------------- transferred device – success @@ -156,12 +163,14 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled { failedState -> + userDao.findById(1).shouldNotBeNull() failedState.giveUp() // unblock the flow so the test completes } states.any { it is State.RestoringFromBackupFailed }.shouldBeTrue() val failed = states.filterIsInstance().first() failed.cause.shouldBeInstanceOf() + userDao.findById(1).shouldBeNull() } // --------------------------------------------------- retry @@ -212,10 +221,14 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // User 2 was transferred: restoration will fail. userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) - manager.collectStatesUntilSettled { failedState -> failedState.giveUp() } + manager.collectStatesUntilSettled { failedState -> + userDao.findById(2).shouldNotBeNull() + failedState.giveUp() + } // Only the user whose restoration failed must be removed. - userDao.findById(2) shouldBe null + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldBeNull() } // ------------------------------------------------------------------ helpers From 8086fe8a392eb06bf9413b0b86b9a40239fcdc64 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 18 Aug 2026 15:37:12 +0200 Subject: [PATCH 43/56] test: Update function names to be more readable --- .../RestoreFromBackupManagerImplTest.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt index ecc58d16a..37dac4491 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:Suppress("NonAsciiCharacters") + package com.infomaniak.core.auth.backup import com.infomaniak.core.auth.BaseAccountUtilsTest @@ -57,7 +59,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- no users @Test - fun noUsers_settlesImmediately() = test { _ -> + fun `no users → settles immediately`() = test { _ -> val states = manager.collectStatesUntilSettled() states shouldBe listOf(State.Settled) @@ -66,7 +68,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- same device @Test - fun sameDevice_settlesImmediately() = test { userDao -> + fun `same device → settles immediately`() = test { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) val states = manager.collectStatesUntilSettled() @@ -76,7 +78,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allSameDevice_settlesImmediately() = test { userDao -> + fun `multiple users, all same device → settles immediately`() = test { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) userDao.insertUserWithBinding(userId = 2, androidId = currentAndroidId) userDao.insertUserWithBinding(userId = 3, androidId = currentAndroidId) @@ -92,7 +94,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- pre-v9: missing binding @Test - fun missingBinding_addsBindingAndSettles() = test { userDao -> + fun `missing binding → adds binding and settles`() = test { userDao -> userDao.insertUserWithoutBinding(userId = 1) val states = manager.collectStatesUntilSettled() @@ -104,7 +106,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allMissingBindings_addsBindingsAndSettles() = test { userDao -> + fun `multiple users, all missing bindings → adds bindings and settles`() = test { userDao -> userDao.insertUserWithoutBinding(userId = 1) userDao.insertUserWithoutBinding(userId = 2) @@ -120,7 +122,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- transferred device – success @Test - fun transferredDevice_derivationSucceeds_updatesTokenAndSettles() = test { userDao -> + fun `transferred device, derivation succeeds → updates token and settles`() = test { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) val states = manager.collectStatesUntilSettled() @@ -131,7 +133,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_allTransferred_allSucceed_settles() = test(timeout = 2.seconds) { userDao -> + fun `multiple users, all transferred, all succeed → settles`() = test(timeout = 2.seconds) { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) @@ -143,7 +145,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_mixedDevices_onlyTransferredAreRestored() = test { userDao -> + fun `multiple users, mixed devices → only transferred are restored`() = test { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration @@ -157,7 +159,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- restoration failure @Test - fun transferredDevice_derivationFails_emitsRestoringFromBackupFailedState() = test( + fun `transferred device, derivation fails → emits RestoringFromBackupFailed state`() = test( initialTokenDerivationResult = networkFailure() ) { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -176,7 +178,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- retry @Test - fun transferredDevice_derivationFails_thenRetry_succeeds() = test( + fun `transferred device, derivation fails, then retry → succeeds`() = test( initialTokenDerivationResult = networkFailure() ) { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -199,7 +201,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { // --------------------------------------------------- give-up @Test - fun transferredDevice_derivationFails_giveUp_removesFailedUserAndSettles() = test( + fun `transferred device, derivation fails, give up → removes failed user and settles`() = test( initialTokenDerivationResult = networkFailure() ) { userDao -> userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) @@ -213,7 +215,7 @@ class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { } @Test - fun multipleUsers_partialFailure_giveUp_onlyRemovesFailedUser() = test( + fun `multiple users, partial failure, give up → only removes failed user`() = test( initialTokenDerivationResult = networkFailure() ) { userDao -> // User 1 is on the current device: no restoration required, must not be removed. From 80eca9e2aac0376090642bb7723e648d617a183d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 11 Aug 2026 17:44:38 +0200 Subject: [PATCH 44/56] chore(CrossAppLogin): Avoid disabling cross app login in more cases --- .../core/auth/DerivedTokenGenerator.IssueExtensions.kt | 2 +- .../core/crossapplogin/back/BaseCrossAppLoginViewModel.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt index 6a50ec12c..ac00da83a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -27,7 +27,7 @@ fun Issue.shouldReport(): Boolean = when (this) { is Issue.OtherIssue -> true } -internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { +fun Issue.shouldRetryAutomatically(): Boolean = when (this) { is Issue.AppIntegrityCheckFailed -> shouldRetryAutomatically() is Issue.ErrorResponse -> shouldRetryAutomatically() is Issue.NetworkIssue -> true diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 8400c6c50..a3f0e527b 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -26,6 +26,7 @@ import com.infomaniak.core.auth.DerivedTokenGeneratorImpl import com.infomaniak.core.auth.api.ApiRepositoryCore import com.infomaniak.core.auth.api.ApiRoutesCore.TOKEN_URL import com.infomaniak.core.auth.shouldReport +import com.infomaniak.core.auth.shouldRetryAutomatically import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.completableScope @@ -192,7 +193,7 @@ internal class CrossAppLoginFacadeImpl( tokens.add(result.value) } is Xor.Second -> { - hadATerminalIssue = hadATerminalIssue || result.value !is Issue.NetworkIssue + hadATerminalIssue = hadATerminalIssue || !result.value.shouldRetryAutomatically() errorMessageIds.add(getTokenDerivationIssueErrorMessage(account, issue = result.value)) } } From 2a3cb1e31b5b266796b76baa6cba2d9c34dd48a3 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 24 Aug 2026 18:46:26 +0200 Subject: [PATCH 45/56] feat: Add BackupAgent helpers --- .../core/common/backup/BackupAgentBase.kt | 61 +++++++++++++++ .../core/common/backup/BackupDataOutput.kt | 29 +++++++ .../backup/TransportAwareFileBackupHandler.kt | 78 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt new file mode 100644 index 000000000..2f7f24741 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt @@ -0,0 +1,61 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgentHelper +import android.app.backup.BackupDataInputStream +import android.app.backup.BackupDataOutput +import android.app.backup.BackupHelper +import android.os.ParcelFileDescriptor + +abstract class BackupAgentBase : BackupAgentHelper() { + + /** + * Consider using the [BackupHandler] class for [BackupHelper] implementations, + * to have the correct parameters nullability. + * + * @return The key prefixes associated with their helper. + * @see addHelper + * @see BackupHandler + */ + abstract fun createBackupHelpers(): Map + + /** + * Defines the nullability for easier implementation. + * + * @see BackupHelper + */ + abstract class BackupHandler : BackupHelper { + abstract override fun performBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput, + newState: ParcelFileDescriptor + ) + + abstract override fun restoreEntity(data: BackupDataInputStream) + + abstract override fun writeNewStateDescription(newState: ParcelFileDescriptor) + } + + final override fun onCreate() { + val handlers = createBackupHelpers() + handlers.forEach { (keyPrefix, handler) -> + addHelper(keyPrefix, handler) + } + } +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt new file mode 100644 index 000000000..d7119b66e --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt @@ -0,0 +1,29 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataOutput +import android.os.Build.VERSION.SDK_INT +import splitties.bitflags.hasFlag + +internal val BackupDataOutput.isDeviceToDeviceTransfer: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else null + +internal val BackupDataOutput.isClientSideEncryptionEnabled: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else null diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt new file mode 100644 index 000000000..507bf6c8a --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt @@ -0,0 +1,78 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupDataInputStream +import android.app.backup.BackupDataOutput +import android.app.backup.FileBackupHelper +import android.content.Context +import android.os.ParcelFileDescriptor + +class TransportAwareFileBackupHandler( + context: Context, + filesToBackup: Map, +) : BackupAgentBase.BackupHandler() { + + enum class CloudBackupPolicy { + Skip, OnlyIfDeviceEncrypted, Always; + } + + private val deviceToDeviceBackupHelper by lazy { + FileBackupHelper(context, *filesToBackup.keys.toTypedArray()) + } + + private val deviceEncryptedCloudBackupHelper by lazy { + val filesToBackup = filesToBackup.mapNotNull { (name, policy) -> + when (policy) { + CloudBackupPolicy.OnlyIfDeviceEncrypted, CloudBackupPolicy.Always -> name + CloudBackupPolicy.Skip -> null + } + }.toTypedArray() + FileBackupHelper(context, *filesToBackup) + } + + private val fallbackBackupHelper by lazy { + val filesToBackup = filesToBackup.mapNotNull { (name, policy) -> + when (policy) { + CloudBackupPolicy.Always -> name + CloudBackupPolicy.OnlyIfDeviceEncrypted, CloudBackupPolicy.Skip -> null + } + }.toTypedArray() + FileBackupHelper(context, *filesToBackup) + } + + override fun performBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput, + newState: ParcelFileDescriptor + ) = when { + data.isDeviceToDeviceTransfer == true -> deviceToDeviceBackupHelper.performBackup(oldState, data, newState) + data.isClientSideEncryptionEnabled == true -> deviceEncryptedCloudBackupHelper.performBackup(oldState, data, newState) + else -> fallbackBackupHelper.performBackup(oldState, data, newState) + } + + override fun restoreEntity(data: BackupDataInputStream) { + // Forward it to the helper that can handle all the files. + deviceToDeviceBackupHelper.restoreEntity(data) + } + + override fun writeNewStateDescription(newState: ParcelFileDescriptor) { + // Forward it to the helper that can handle all the files. + deviceToDeviceBackupHelper.writeNewStateDescription(newState) + } +} From 47bd369e6b062c3a04dbb370f7297b29b46279f2 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 25 Aug 2026 18:14:59 +0200 Subject: [PATCH 46/56] feat: Update TransportAwareFileBackupHandler for more granularity --- .../backup/TransportAwareFileBackupHandler.kt | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt index 507bf6c8a..ef674d738 100644 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt @@ -17,62 +17,67 @@ */ package com.infomaniak.core.common.backup +import android.app.backup.BackupAgent import android.app.backup.BackupDataInputStream import android.app.backup.BackupDataOutput import android.app.backup.FileBackupHelper -import android.content.Context import android.os.ParcelFileDescriptor class TransportAwareFileBackupHandler( - context: Context, - filesToBackup: Map, + private val context: BackupAgent, + private val filesToBackup: Map, ) : BackupAgentBase.BackupHandler() { - enum class CloudBackupPolicy { - Skip, OnlyIfDeviceEncrypted, Always; + enum class BackupPolicy { + EncryptedDeviceToDeviceOnly, + DeviceToDeviceOnly, + DeviceEncryptedCloudAllowed, + DeviceEncryptedOnly, + CloudAllowed; } - private val deviceToDeviceBackupHelper by lazy { + private val encryptedDeviceToDeviceBackupHelper by lazy { FileBackupHelper(context, *filesToBackup.keys.toTypedArray()) } - private val deviceEncryptedCloudBackupHelper by lazy { - val filesToBackup = filesToBackup.mapNotNull { (name, policy) -> - when (policy) { - CloudBackupPolicy.OnlyIfDeviceEncrypted, CloudBackupPolicy.Always -> name - CloudBackupPolicy.Skip -> null - } - }.toTypedArray() - FileBackupHelper(context, *filesToBackup) - } - - private val fallbackBackupHelper by lazy { - val filesToBackup = filesToBackup.mapNotNull { (name, policy) -> - when (policy) { - CloudBackupPolicy.Always -> name - CloudBackupPolicy.OnlyIfDeviceEncrypted, CloudBackupPolicy.Skip -> null - } - }.toTypedArray() - FileBackupHelper(context, *filesToBackup) - } - override fun performBackup( oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor - ) = when { - data.isDeviceToDeviceTransfer == true -> deviceToDeviceBackupHelper.performBackup(oldState, data, newState) - data.isClientSideEncryptionEnabled == true -> deviceEncryptedCloudBackupHelper.performBackup(oldState, data, newState) - else -> fallbackBackupHelper.performBackup(oldState, data, newState) + ) { + val helper = fileBackupHelper(data) + helper.performBackup(oldState, data, newState) } override fun restoreEntity(data: BackupDataInputStream) { // Forward it to the helper that can handle all the files. - deviceToDeviceBackupHelper.restoreEntity(data) + encryptedDeviceToDeviceBackupHelper.restoreEntity(data) } override fun writeNewStateDescription(newState: ParcelFileDescriptor) { // Forward it to the helper that can handle all the files. - deviceToDeviceBackupHelper.writeNewStateDescription(newState) + encryptedDeviceToDeviceBackupHelper.writeNewStateDescription(newState) + } + + private fun allowedPolicies(data: BackupDataOutput): Set = buildSet { + add(BackupPolicy.CloudAllowed) + val clientEncrypted = data.isClientSideEncryptionEnabled == true + if (data.isDeviceToDeviceTransfer == true) { + add(BackupPolicy.DeviceToDeviceOnly) + if (clientEncrypted) add(BackupPolicy.EncryptedDeviceToDeviceOnly) + } + if (clientEncrypted) { + add(BackupPolicy.DeviceEncryptedOnly) + add(BackupPolicy.DeviceEncryptedCloudAllowed) + } + } + + private fun fileBackupHelper(data: BackupDataOutput): FileBackupHelper { + if (data.isDeviceToDeviceTransfer == true && data.isClientSideEncryptionEnabled == true) { + return encryptedDeviceToDeviceBackupHelper + } + val allowedPolicies = allowedPolicies(data) + val files = filesToBackup.mapNotNull { (name, policy) -> name.takeIf { policy in allowedPolicies } } + return FileBackupHelper(context, *files.toTypedArray()) } } From 8327fad4f11a4b52b9bbdaa75ea93792d6a6c6d0 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 31 Aug 2026 15:20:17 +0200 Subject: [PATCH 47/56] refactor: Extract BackupPolicy to separate file --- .../core/common/backup/AppBackupPolicy.kt | 27 +++++++++++++++++++ .../backup/TransportAwareFileBackupHandler.kt | 22 +++++---------- 2 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt new file mode 100644 index 000000000..c5b019df7 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt @@ -0,0 +1,27 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +enum class AppBackupPolicy { + EncryptedDeviceToDeviceOnly, + DeviceToDeviceOnly, + DeviceEncryptedCloudAllowed, + DeviceEncryptedOnly, + CloudAllowed, + ; +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt index ef674d738..9aad393c1 100644 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt @@ -25,17 +25,9 @@ import android.os.ParcelFileDescriptor class TransportAwareFileBackupHandler( private val context: BackupAgent, - private val filesToBackup: Map, + private val filesToBackup: Map, ) : BackupAgentBase.BackupHandler() { - enum class BackupPolicy { - EncryptedDeviceToDeviceOnly, - DeviceToDeviceOnly, - DeviceEncryptedCloudAllowed, - DeviceEncryptedOnly, - CloudAllowed; - } - private val encryptedDeviceToDeviceBackupHelper by lazy { FileBackupHelper(context, *filesToBackup.keys.toTypedArray()) } @@ -59,16 +51,16 @@ class TransportAwareFileBackupHandler( encryptedDeviceToDeviceBackupHelper.writeNewStateDescription(newState) } - private fun allowedPolicies(data: BackupDataOutput): Set = buildSet { - add(BackupPolicy.CloudAllowed) + private fun allowedPolicies(data: BackupDataOutput): Set = buildSet { + add(AppBackupPolicy.CloudAllowed) val clientEncrypted = data.isClientSideEncryptionEnabled == true if (data.isDeviceToDeviceTransfer == true) { - add(BackupPolicy.DeviceToDeviceOnly) - if (clientEncrypted) add(BackupPolicy.EncryptedDeviceToDeviceOnly) + add(AppBackupPolicy.DeviceToDeviceOnly) + if (clientEncrypted) add(AppBackupPolicy.EncryptedDeviceToDeviceOnly) } if (clientEncrypted) { - add(BackupPolicy.DeviceEncryptedOnly) - add(BackupPolicy.DeviceEncryptedCloudAllowed) + add(AppBackupPolicy.DeviceEncryptedOnly) + add(AppBackupPolicy.DeviceEncryptedCloudAllowed) } } From b960fc85b8e4ecd3dc4e6a5f29da61feca4d787a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 31 Aug 2026 15:31:28 +0200 Subject: [PATCH 48/56] chore: Remove BackupAgentBase and TransportAwareFileBackupHandler --- .../core/common/backup/AppBackupPolicy.kt | 27 ------- .../core/common/backup/BackupAgentBase.kt | 61 --------------- .../backup/TransportAwareFileBackupHandler.kt | 75 ------------------- 3 files changed, 163 deletions(-) delete mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt delete mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt delete mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt deleted file mode 100644 index c5b019df7..000000000 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/AppBackupPolicy.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Infomaniak Core - Android - * Copyright (C) 2026 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 . - */ -package com.infomaniak.core.common.backup - -enum class AppBackupPolicy { - EncryptedDeviceToDeviceOnly, - DeviceToDeviceOnly, - DeviceEncryptedCloudAllowed, - DeviceEncryptedOnly, - CloudAllowed, - ; -} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt deleted file mode 100644 index 2f7f24741..000000000 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupAgentBase.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Infomaniak Core - Android - * Copyright (C) 2026 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 . - */ -package com.infomaniak.core.common.backup - -import android.app.backup.BackupAgentHelper -import android.app.backup.BackupDataInputStream -import android.app.backup.BackupDataOutput -import android.app.backup.BackupHelper -import android.os.ParcelFileDescriptor - -abstract class BackupAgentBase : BackupAgentHelper() { - - /** - * Consider using the [BackupHandler] class for [BackupHelper] implementations, - * to have the correct parameters nullability. - * - * @return The key prefixes associated with their helper. - * @see addHelper - * @see BackupHandler - */ - abstract fun createBackupHelpers(): Map - - /** - * Defines the nullability for easier implementation. - * - * @see BackupHelper - */ - abstract class BackupHandler : BackupHelper { - abstract override fun performBackup( - oldState: ParcelFileDescriptor?, - data: BackupDataOutput, - newState: ParcelFileDescriptor - ) - - abstract override fun restoreEntity(data: BackupDataInputStream) - - abstract override fun writeNewStateDescription(newState: ParcelFileDescriptor) - } - - final override fun onCreate() { - val handlers = createBackupHelpers() - handlers.forEach { (keyPrefix, handler) -> - addHelper(keyPrefix, handler) - } - } -} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt deleted file mode 100644 index 9aad393c1..000000000 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/TransportAwareFileBackupHandler.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Infomaniak Core - Android - * Copyright (C) 2026 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 . - */ -package com.infomaniak.core.common.backup - -import android.app.backup.BackupAgent -import android.app.backup.BackupDataInputStream -import android.app.backup.BackupDataOutput -import android.app.backup.FileBackupHelper -import android.os.ParcelFileDescriptor - -class TransportAwareFileBackupHandler( - private val context: BackupAgent, - private val filesToBackup: Map, -) : BackupAgentBase.BackupHandler() { - - private val encryptedDeviceToDeviceBackupHelper by lazy { - FileBackupHelper(context, *filesToBackup.keys.toTypedArray()) - } - - override fun performBackup( - oldState: ParcelFileDescriptor?, - data: BackupDataOutput, - newState: ParcelFileDescriptor - ) { - val helper = fileBackupHelper(data) - helper.performBackup(oldState, data, newState) - } - - override fun restoreEntity(data: BackupDataInputStream) { - // Forward it to the helper that can handle all the files. - encryptedDeviceToDeviceBackupHelper.restoreEntity(data) - } - - override fun writeNewStateDescription(newState: ParcelFileDescriptor) { - // Forward it to the helper that can handle all the files. - encryptedDeviceToDeviceBackupHelper.writeNewStateDescription(newState) - } - - private fun allowedPolicies(data: BackupDataOutput): Set = buildSet { - add(AppBackupPolicy.CloudAllowed) - val clientEncrypted = data.isClientSideEncryptionEnabled == true - if (data.isDeviceToDeviceTransfer == true) { - add(AppBackupPolicy.DeviceToDeviceOnly) - if (clientEncrypted) add(AppBackupPolicy.EncryptedDeviceToDeviceOnly) - } - if (clientEncrypted) { - add(AppBackupPolicy.DeviceEncryptedOnly) - add(AppBackupPolicy.DeviceEncryptedCloudAllowed) - } - } - - private fun fileBackupHelper(data: BackupDataOutput): FileBackupHelper { - if (data.isDeviceToDeviceTransfer == true && data.isClientSideEncryptionEnabled == true) { - return encryptedDeviceToDeviceBackupHelper - } - val allowedPolicies = allowedPolicies(data) - val files = filesToBackup.mapNotNull { (name, policy) -> name.takeIf { policy in allowedPolicies } } - return FileBackupHelper(context, *files.toTypedArray()) - } -} From 692f3a3e12eee11bc15d2a636a3c31956ee2073d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 31 Aug 2026 15:31:52 +0200 Subject: [PATCH 49/56] feat: Introduce FullBackupAgent --- .../core/common/backup/FullBackupAgent.kt | 92 +++++++++++++++++++ .../common/backup/FullBackupDataOutput.kt | 29 ++++++ 2 files changed, 121 insertions(+) create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt new file mode 100644 index 000000000..465ae9e5a --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt @@ -0,0 +1,92 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +import android.R.attr.data +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.FullBackupDataOutput +import android.os.ParcelFileDescriptor +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream + +/** + * The Android Backup API design and documentation are very confusing, because there are 2 different systems, + * not clearly stated in the relevant methods, so here's a summary of each system: + * + * ## 1. Key/value backup + * + * This is a legacy system that gives a lot of flexibility, incremental backups… but only 5MB of storage, + * and a very confusing API, mainly because of its documentation that lacks clarity about what each function is and should do. + * + * ## 2. Full backup / AutoBackup + * + * This is also very poorly documented, and the relation between the programmatic APIs and the 2 different XML syntaxes + * is documented in a very unclear way. + * + * Anyway, this API is the one to use, with XML rules, and with overrides of [onFullBackup] and [onRestoreFile] for specific + * cases, like extracting some data from a DB that shouldn't be fully backed-up, as a temporary file that is staged for backup. + */ +abstract class FullBackupAgent : BackupAgent() { + + /** + * Helper function to read the right amount of bytes from [data] directly into a ByteArray. + * Designed to be used in [onRestoreFile] overrides. + */ + protected fun ParcelFileDescriptor.toByteArray(size: Long): ByteArray { + val sizeInBytes = size.toInt() + check(sizeInBytes.toLong() == size) + return ByteArray(sizeInBytes).also { destination -> + val readyBytesCount = DataInputStream(FileInputStream(fileDescriptor)).read(destination) + check(readyBytesCount == destination.size) + } + } + + @Suppress("RedundantOverride") // Allows specifying the nullability. + override fun onFullBackup(data: FullBackupDataOutput) { + super.onFullBackup(data) + } + + @Suppress("RedundantOverride") // Allows specifying the nullability. + override fun onRestoreFile( + data: ParcelFileDescriptor, + size: Long, + destination: File, + type: Int, + mode: Long, + mtime: Long + ) { + super.onRestoreFile(data, size, destination, type, mode, mtime) + } + + // Never called for full backup. + final override fun onBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput?, + newState: ParcelFileDescriptor? + ) = Unit + + // Never called for full backup. + final override fun onRestore( + data: BackupDataInput?, + appVersionCode: Int, + newState: ParcelFileDescriptor? + ) = Unit +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt new file mode 100644 index 000000000..7a8bf70d9 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt @@ -0,0 +1,29 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 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 . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgent +import android.app.backup.FullBackupDataOutput +import android.os.Build.VERSION.SDK_INT +import splitties.bitflags.hasFlag + +val FullBackupDataOutput.isDeviceToDeviceTransfer: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else null + +val FullBackupDataOutput.isClientSideEncryptionEnabled: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else null From 0380a29db02b4ecdcefb661bac43a860c514b7b6 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 1 Sep 2026 15:42:02 +0200 Subject: [PATCH 50/56] feat: Add FullBackup to restore file not included in XML rules The code was copied for AOSP. --- .../core/common/backup/FullBackup.java | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java new file mode 100644 index 000000000..112ffe7b2 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.infomaniak.core.common.backup; + +import android.app.backup.BackupAgent; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.system.ErrnoException; +import android.system.Os; +import android.util.Log; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * Copied from android.app.backup.FullBackup. + *

+ * Global constant definitions et cetera related to the full-backup-to-fd + * binary format. Nothing in this namespace is part of any API; it's all + * hidden details of the current implementation gathered into one location. + * + */ +@SuppressWarnings("ALL") // Ignore warnings from code copied from AOSP. +public class FullBackup { + static final String TAG = "FullBackup"; + + /** + * Copy data from a socket to the given File location on permanent storage. The + * modification time and access mode of the resulting file will be set if desired, + * although group/all rwx modes will be stripped: the restored file will not be + * accessible from outside the target application even if the original file was. + * If the {@code type} parameter indicates that the result should be a directory, + * the socket parameter may be {@code null}; even if it is valid, no data will be + * read from it in this case. + *

+ * If the {@code mode} argument is negative, then the resulting output file will not + * have its access mode or last modification time reset as part of this operation. + * + * @param data Socket supplying the data to be copied to the output file. If the + * output is a directory, this may be {@code null}. + * @param size Number of bytes of data to copy from the socket to the file. At least + * this much data must be available through the {@code data} parameter. + * @param type Must be either {@link BackupAgent#TYPE_FILE} for ordinary file data + * or {@link BackupAgent#TYPE_DIRECTORY} for a directory. + * @param mode Unix-style file mode (as used by the chmod(2) syscall) to be set on + * the output file or directory. group/all rwx modes are stripped even if set + * in this parameter. If this parameter is negative then neither + * the mode nor the mtime values will be applied to the restored file. + * @param mtime A timestamp in the standard Unix epoch that will be imposed as the + * last modification time of the output file. if the {@code mode} parameter is + * negative then this parameter will be ignored. + * @param outFile Location within the filesystem to place the data. This must point + * to a location that is writeable by the caller, preferably using an absolute path. + * @throws IOException + */ + static public void restoreFile(ParcelFileDescriptor data, + long size, int type, long mode, long mtime, File outFile) throws IOException { + if (type == BackupAgent.TYPE_DIRECTORY) { + // Canonically a directory has no associated content, so we don't need to read + // anything from the pipe in this case. Just create the directory here and + // drop down to the final metadata adjustment. + if (outFile != null) outFile.mkdirs(); + } else { + FileOutputStream out = null; + + // Pull the data from the pipe, copying it to the output file, until we're done + try { + if (outFile != null) { + File parent = outFile.getParentFile(); + if (!parent.exists()) { + // in practice this will only be for the default semantic directories, + // and using the default mode for those is appropriate. + // This can also happen for the case where a parent directory has been + // excluded, but a file within that directory has been included. + parent.mkdirs(); + } + out = new FileOutputStream(outFile); + } + } catch (IOException e) { + Log.e(TAG, "Unable to create/open file " + outFile.getPath(), e); + } + + byte[] buffer = new byte[64 * 1024]; + final long origSize = size; + FileInputStream in = new FileInputStream(data.getFileDescriptor()); + while (size > 0) { + int toRead = (size > buffer.length) ? buffer.length : (int)size; + int got = in.read(buffer, 0, toRead); + if (got <= 0) { + Log.w(TAG, "Incomplete read: expected " + size + " but got " + + (origSize - size)); + break; + } + if (out != null) { + try { + out.write(buffer, 0, got); + } catch (IOException e) { + // Problem writing to the file. Quit copying data and delete + // the file, but of course keep consuming the input stream. + Log.e(TAG, "Unable to write to file " + outFile.getPath(), e); + out.close(); + out = null; + outFile.delete(); + } + } + size -= got; + } + if (out != null) out.close(); + } + + // Now twiddle the state to match the backup, assuming all went well + if (mode >= 0 && outFile != null) { + try { + // explicitly prevent emplacement of files accessible by outside apps + mode &= 0700; + Os.chmod(outFile.getPath(), (int)mode); + } catch (ErrnoException e) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + e.rethrowAsIOException(); + } else throw new IOException(e); + } + outFile.setLastModified(mtime); + } + } +} From f179c7942677a66f6afd135e0cba28d5aec5248e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 1 Sep 2026 16:42:18 +0200 Subject: [PATCH 51/56] refactor: Rename .java to .kt --- .../core/common/backup/{FullBackup.java => FullBackup.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Common/src/main/kotlin/com/infomaniak/core/common/backup/{FullBackup.java => FullBackup.kt} (100%) diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt similarity index 100% rename from Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.java rename to Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt From 9f6c202c14d4e8f0d37e5a7fa960b4ae18a27667 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 1 Sep 2026 16:42:19 +0200 Subject: [PATCH 52/56] fix: Fix compilation by converting FullBackup.java to Kotlin Compilation of Java sources isn't enabled in this module. --- .../core/common/backup/FullBackup.kt | 137 ++++++++++-------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt index 112ffe7b2..64c8124db 100644 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt @@ -13,128 +13,137 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.infomaniak.core.common.backup -package com.infomaniak.core.common.backup; - -import android.app.backup.BackupAgent; -import android.os.Build; -import android.os.ParcelFileDescriptor; -import android.system.ErrnoException; -import android.system.Os; -import android.util.Log; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; +import android.app.backup.BackupAgent +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.util.Log +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException /** - * Copied from android.app.backup.FullBackup. - *

+ * Copied from `android.app.backup.FullBackup`, then converted to Kotlin so we don't need to enable Java compilation. + * + * * Global constant definitions et cetera related to the full-backup-to-fd * binary format. Nothing in this namespace is part of any API; it's all * hidden details of the current implementation gathered into one location. * */ -@SuppressWarnings("ALL") // Ignore warnings from code copied from AOSP. -public class FullBackup { - static final String TAG = "FullBackup"; +// Ignore warnings from code copied from AOSP. +object FullBackup { + const val TAG: String = "FullBackup" /** * Copy data from a socket to the given File location on permanent storage. The * modification time and access mode of the resulting file will be set if desired, * although group/all rwx modes will be stripped: the restored file will not be * accessible from outside the target application even if the original file was. - * If the {@code type} parameter indicates that the result should be a directory, - * the socket parameter may be {@code null}; even if it is valid, no data will be + * If the `type` parameter indicates that the result should be a directory, + * the socket parameter may be `null`; even if it is valid, no data will be * read from it in this case. - *

- * If the {@code mode} argument is negative, then the resulting output file will not + * + * + * If the `mode` argument is negative, then the resulting output file will not * have its access mode or last modification time reset as part of this operation. * * @param data Socket supplying the data to be copied to the output file. If the - * output is a directory, this may be {@code null}. + * output is a directory, this may be `null`. * @param size Number of bytes of data to copy from the socket to the file. At least - * this much data must be available through the {@code data} parameter. - * @param type Must be either {@link BackupAgent#TYPE_FILE} for ordinary file data - * or {@link BackupAgent#TYPE_DIRECTORY} for a directory. + * this much data must be available through the `data` parameter. + * @param type Must be either [BackupAgent.TYPE_FILE] for ordinary file data + * or [BackupAgent.TYPE_DIRECTORY] for a directory. * @param mode Unix-style file mode (as used by the chmod(2) syscall) to be set on - * the output file or directory. group/all rwx modes are stripped even if set - * in this parameter. If this parameter is negative then neither - * the mode nor the mtime values will be applied to the restored file. + * the output file or directory. group/all rwx modes are stripped even if set + * in this parameter. If this parameter is negative then neither + * the mode nor the mtime values will be applied to the restored file. * @param mtime A timestamp in the standard Unix epoch that will be imposed as the - * last modification time of the output file. if the {@code mode} parameter is - * negative then this parameter will be ignored. + * last modification time of the output file. if the `mode` parameter is + * negative then this parameter will be ignored. * @param outFile Location within the filesystem to place the data. This must point - * to a location that is writeable by the caller, preferably using an absolute path. + * to a location that is writeable by the caller, preferably using an absolute path. * @throws IOException */ - static public void restoreFile(ParcelFileDescriptor data, - long size, int type, long mode, long mtime, File outFile) throws IOException { + @Throws(IOException::class) + fun restoreFile( + data: ParcelFileDescriptor, + size: Long, + type: Int, + mode: Long, + mtime: Long, + outFile: File? + ) { + var size = size + var mode = mode if (type == BackupAgent.TYPE_DIRECTORY) { // Canonically a directory has no associated content, so we don't need to read // anything from the pipe in this case. Just create the directory here and // drop down to the final metadata adjustment. - if (outFile != null) outFile.mkdirs(); + outFile?.mkdirs() } else { - FileOutputStream out = null; + var out: FileOutputStream? = null // Pull the data from the pipe, copying it to the output file, until we're done try { if (outFile != null) { - File parent = outFile.getParentFile(); - if (!parent.exists()) { + val parent = outFile.getParentFile() + if (!parent!!.exists()) { // in practice this will only be for the default semantic directories, // and using the default mode for those is appropriate. // This can also happen for the case where a parent directory has been // excluded, but a file within that directory has been included. - parent.mkdirs(); + parent.mkdirs() } - out = new FileOutputStream(outFile); + out = FileOutputStream(outFile) } - } catch (IOException e) { - Log.e(TAG, "Unable to create/open file " + outFile.getPath(), e); + } catch (e: IOException) { + Log.e(TAG, "Unable to create/open file " + outFile!!.path, e) } - byte[] buffer = new byte[64 * 1024]; - final long origSize = size; - FileInputStream in = new FileInputStream(data.getFileDescriptor()); + val buffer = ByteArray(64 * 1024) + val origSize = size + val `in` = FileInputStream(data.fileDescriptor) while (size > 0) { - int toRead = (size > buffer.length) ? buffer.length : (int)size; - int got = in.read(buffer, 0, toRead); + val toRead = if (size > buffer.size) buffer.size else size.toInt() + val got = `in`.read(buffer, 0, toRead) if (got <= 0) { - Log.w(TAG, "Incomplete read: expected " + size + " but got " - + (origSize - size)); - break; + Log.w( + TAG, ("Incomplete read: expected " + size + " but got " + + (origSize - size)) + ) + break } if (out != null) { try { - out.write(buffer, 0, got); - } catch (IOException e) { + out.write(buffer, 0, got) + } catch (e: IOException) { // Problem writing to the file. Quit copying data and delete // the file, but of course keep consuming the input stream. - Log.e(TAG, "Unable to write to file " + outFile.getPath(), e); - out.close(); - out = null; - outFile.delete(); + Log.e(TAG, "Unable to write to file " + outFile!!.path, e) + out.close() + out = null + outFile.delete() } } - size -= got; + size -= got.toLong() } - if (out != null) out.close(); + out?.close() } // Now twiddle the state to match the backup, assuming all went well if (mode >= 0 && outFile != null) { try { // explicitly prevent emplacement of files accessible by outside apps - mode &= 0700; - Os.chmod(outFile.getPath(), (int)mode); - } catch (ErrnoException e) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - e.rethrowAsIOException(); - } else throw new IOException(e); + mode = mode and 448L // 0700 in octal notation. + Os.chmod(outFile.path, mode.toInt()) + } catch (e: ErrnoException) { + throw IOException(e.message).also { it.initCause(e) } } - outFile.setLastModified(mtime); + outFile.setLastModified(mtime) } } } From a7203356cb22659f8f966576c6eb4e8c97345ae3 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 3 Sep 2026 13:18:24 +0200 Subject: [PATCH 53/56] chore: Replace unneeded userDb property with constructor property --- .../core/auth/backup/RestoreFromBackupManagerImpl.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt index 9ed55b4e3..d45466eab 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -54,11 +54,10 @@ import splitties.experimental.ExperimentalSplittiesApi internal class RestoreFromBackupManagerImpl( private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), private val mode: RestorationMode = RestorationMode.TokenDerivation, - userDatabase: UserDatabase = UserDatabase.instance, + private val userDatabase: UserDatabase = UserDatabase.instance, tokenGenerator: DerivedTokenGenerator? = null, ) : RestoreFromBackupManager() { - private val userDb = userDatabase private val userDao = userDatabase.userDao() private val removeUserDeferred = CompletableDeferred Unit>() @@ -142,7 +141,7 @@ internal class RestoreFromBackupManagerImpl( usersToDeriveTokensFor.map { user -> async { when (val result = attemptRestoringAccount(user)) { - is Xor.First -> userDb.useWriterConnection { + is Xor.First -> userDatabase.useWriterConnection { it.immediateTransaction { userDao.update(user.copy(apiToken = result.value)) userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) From 7b3c22d311ce5f91d36a96de10933fa15607d705 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 3 Sep 2026 13:18:48 +0200 Subject: [PATCH 54/56] chore: Move companion object at the end of the file --- .../java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt index 20bb8b651..a99bec2d2 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt @@ -31,10 +31,6 @@ import splitties.init.injectAsAppCtx @RunWith(RobolectricTestRunner::class) abstract class BaseAccountUtilsTest { - companion object { - const val testAndroidId = "test_android_id" - } - protected var context: Context = ApplicationProvider.getApplicationContext() init { @@ -65,4 +61,8 @@ abstract class BaseAccountUtilsTest { organizations = ArrayList(), ) } + + companion object { + const val testAndroidId = "test_android_id" + } } From 10034de4c97334a6eb48991a0ecc7862ae9bd9ee Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 3 Sep 2026 13:30:22 +0200 Subject: [PATCH 55/56] chore: Make BaseAccountUtilsTest companion object protected --- .../test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt index a99bec2d2..291639c7d 100644 --- a/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt +++ b/Auth/src/test/java/com/infomaniak/core/auth/BaseAccountUtilsTest.kt @@ -62,7 +62,7 @@ abstract class BaseAccountUtilsTest { ) } - companion object { + protected companion object { const val testAndroidId = "test_android_id" } } From 07d3b8fbd217419aa1a6da2cc87599f50060953e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 3 Sep 2026 14:03:56 +0200 Subject: [PATCH 56/56] docs: Add KDoc and remove unneeded nullability --- .../core/common/backup/FullBackupDataOutput.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt index 7a8bf70d9..2c2a0e9e5 100644 --- a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt @@ -22,8 +22,15 @@ import android.app.backup.FullBackupDataOutput import android.os.Build.VERSION.SDK_INT import splitties.bitflags.hasFlag -val FullBackupDataOutput.isDeviceToDeviceTransfer: Boolean? - get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else null +/** + * If true, we don't have the 25MB limit. + * + * Before API 28, we can't know if it's a device-to-device transfer, or a cloud backup. + * Since it's going through the same pipeline either way, and since the 25MB limit + * is applied in both cases, we consider that we're not in the device-to-device transfer case. + */ +val FullBackupDataOutput.isDeviceToDeviceTransfer: Boolean + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else false -val FullBackupDataOutput.isClientSideEncryptionEnabled: Boolean? - get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else null +val FullBackupDataOutput.isClientSideEncryptionEnabled: Boolean + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else false