From 8136a4af2ebcc369e170f0cefe8dcdaf62affe2f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 7 Jul 2026 17:44:53 +0200 Subject: [PATCH 01/26] 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 4d45c302d..bcbc1c8c5 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 b4c0a39b6a0bc1191f07ae488e486ea9b046ef16 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 7 Jul 2026 18:49:49 +0200 Subject: [PATCH 02/26] 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 5e7e77706..21e9e7c3a 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 f029e274e95a756e93dd10c34185fb2473b54b5a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 8 Jul 2026 18:07:22 +0200 Subject: [PATCH 03/26] 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 64c361d4f..ec973b32e 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 790418bf2..22b7613b2 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 @@ -30,7 +30,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 09b644213ff25ebf107b7142cb85c8d4ea71c637 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 8 Jul 2026 18:07:54 +0200 Subject: [PATCH 04/26] 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 dc684b3e7..7ad2506bb 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -49,6 +49,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 21e9e7c3a..f0bcccc00 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 1d241385ed27f5048b0eddc1c953914719e5df4e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 9 Jul 2026 18:38:36 +0200 Subject: [PATCH 05/26] 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 a5bdeaee655ccbfc087b6e0e0905e9a6c163648e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 9 Jul 2026 18:57:25 +0200 Subject: [PATCH 06/26] chore: Store user token to device association in the db instead of a file --- Auth/build.gradle.kts | 1 - .../9.json | 203 ++++++++++++++++++ ... 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, 372 insertions(+), 87 deletions(-) create mode 100644 Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.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 7ad2506bb..dc684b3e7 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -49,7 +49,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 new file mode 100644 index 000000000..027c6e525 --- /dev/null +++ b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json @@ -0,0 +1,203 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "5c16489c4961bd978d19b0d0c0bacb8d", + "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, `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": "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, '5c16489c4961bd978d19b0d0c0bacb8d')" + ] + } +} \ 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 22b7613b2..4ef60de48 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.User import kotlinx.coroutines.flow.Flow @@ -84,4 +85,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 20595dbbe..41af9266e 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,11 +30,12 @@ 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.User import splitties.init.appCtx @Database( - entities = [User::class, CurrentUserId::class], + entities = [User::class, CurrentUserId::class, TokenDeviceBinding::class], autoMigrations = [ AutoMigration( from = 1, to = 2, @@ -52,11 +53,11 @@ import splitties.init.appCtx spec = UserV7Migration::class, ), AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9), ], - version = 8, + version = 9, 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 bcbc1c8c5..821665dda 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 4acd183144ce6c37bcd26bd54618fe99e07a805b Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 09:41:24 +0200 Subject: [PATCH 07/26] 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 e19a8e8709eacbca9c7bec3cb1a41e001bfd9390 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 09:42:44 +0200 Subject: [PATCH 08/26] 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 3b3c6bfab92abf1201ce1b5f043af3ed5d8bf046 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 10:43:15 +0200 Subject: [PATCH 09/26] 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 3dfd5583bdf4eb514b4ae8bae5d5922cf1f95816 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:39:06 +0200 Subject: [PATCH 10/26] 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 5d9b76282b071df708fa715727e75193528527ec Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:47:36 +0200 Subject: [PATCH 11/26] 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 ec973b32e..4cee17f18 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 61065e7963595189d2884146b9d492a1652598dc Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 15:51:49 +0200 Subject: [PATCH 12/26] 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 8ceeeb009e85757879d65f390472863edaa8543d Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 18:26:13 +0200 Subject: [PATCH 13/26] 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 ba5504c671d4ad607c79cf2a90b328279e833f91 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 21 Jul 2026 18:26:54 +0200 Subject: [PATCH 14/26] 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 dc684b3e7..710aeb117 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -44,7 +44,11 @@ dependencies { implementation(platform(core.compose.bom)) 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 b333f799d5639aae641da9c3e991cd4915659978 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Jul 2026 13:24:10 +0200 Subject: [PATCH 15/26] 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 d259d993a6e51c1af0613a1c9061dddff62c6208 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 29 Jul 2026 16:58:21 +0200 Subject: [PATCH 16/26] 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 cc4458138daa75e100458c1b0e36ea7e9c3554d9 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Jul 2026 16:51:19 +0200 Subject: [PATCH 17/26] 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 44cb0efcf57fe0fa2959fbaa5de4145b67c6a5e5 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 31 Jul 2026 09:29:36 +0200 Subject: [PATCH 18/26] 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 199557101f3f88ba2f3c484d5db1ab08a03db268 Mon Sep 17 00:00:00 2001 From: Elouan BOITEUX Date: Fri, 31 Jul 2026 10:10:06 +0200 Subject: [PATCH 19/26] 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 0f16a268e8219fdce79bc218bedc09ac34a0bb76 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:14:05 +0200 Subject: [PATCH 20/26] 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 d756482dbf2b1ec7ca07775baba851ec6ea75608 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:14:48 +0200 Subject: [PATCH 21/26] 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 0aacce88abdaadd05929897c17d02d5519674c62 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:21:17 +0200 Subject: [PATCH 22/26] 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 d64497bc6ac3f15965a3bfada88263ab07343341 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 3 Aug 2026 18:29:45 +0200 Subject: [PATCH 23/26] 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 d413c7f7ed0a34563eab117cf9a59efa5a7bb882 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 11 Aug 2026 17:14:11 +0200 Subject: [PATCH 24/26] 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 3a7c2cabb..39110a789 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt @@ -21,8 +21,8 @@ import androidx.annotation.CallSuper import androidx.collection.ArrayMap 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 @@ -115,7 +115,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 f0bcccc00..d3fec7866 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 4ef60de48..1debd3274 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 @@ -91,4 +91,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 fa795c96877a99edb87dd093e5b7618515e001a1 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 11 Aug 2026 18:05:41 +0200 Subject: [PATCH 25/26] 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 de52876f76cbc507763b28a53ced8290438d8f0e Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 12 Aug 2026 09:30:55 +0200 Subject: [PATCH 26/26] 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 +}