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/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/AbstractCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/AbstractCurrentUserAccountUtils.kt index a5fd6bbed..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 @@ -38,9 +39,10 @@ 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) { + 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/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/CredentialManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt index 64c361d4f..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,12 +32,18 @@ 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? - 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/DerivedTokenGenerator.IssueExtensions.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt new file mode 100644 index 000000000..6a50ec12c --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/DerivedTokenGenerator.IssueExtensions.kt @@ -0,0 +1,45 @@ +/* + * 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 com.infomaniak.core.appintegrity.AppIntegrityIssue +import com.infomaniak.core.auth.DerivedTokenGenerator.Issue + +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 +} + +internal fun Issue.shouldRetryAutomatically(): Boolean = when (this) { + is Issue.AppIntegrityCheckFailed -> shouldRetryAutomatically() + is Issue.ErrorResponse -> shouldRetryAutomatically() + 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 +} + +private fun Issue.ErrorResponse.shouldRetryAutomatically(): Boolean = when (response.code) { + 400, 401 -> false + else -> true +} 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 96% 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..fa9c81f2b 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,9 +39,9 @@ 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 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/PersistedCurrentUserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/PersistedCurrentUserAccountUtils.kt index c78dcac57..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 @@ -28,9 +29,10 @@ 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) { + 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/TokenInterceptor.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/TokenInterceptor.kt index 5e7e77706..d3fec7866 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 @@ -38,6 +39,7 @@ class TokenInterceptor( var request = chain.request() runBlocking(Dispatchers.Default) { + 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/UserAccountUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/UserAccountUtils.kt index e92549ee9..e76c40363 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,14 @@ 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.backup.RestoreFromBackupManager +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]. @@ -31,23 +36,33 @@ import com.infomaniak.core.common.AssociatedUserDataCleanable */ open class UserAccountUtils( appContext: Context, - private val userDataCleanableList: List = emptyList(), + private val userDataCleanableList: () -> List = { emptyList() }, override val userDatabase: UserDatabase = UserDatabase.instantiateDataBase(appContext), + restoreFromBackupManager: RestoreFromBackupManager = RestoreFromBackupManager.instance, ) : BaseCredentialManager() { val users get() = userDao.allUsers + init { + restoreFromBackupManager.registerRemoveUser(::removeUser) + } + /** * @throws SQLiteConstraintException when adding a user with a primary key that already exists */ @CallSuper open suspend fun addUser(user: User) { - userDataCleanableList.forEach { it.resetForUser(user.id.toLong()) } - userDao.insert(user) + userDataCleanableList().forEach { it.resetForUser(user.id.toLong()) } + userDatabase.useWriterConnection { + it.immediateTransaction { + userDao.insert(user) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, getAndroidId())) + } + } } @CallSuper open suspend fun removeUser(userId: Int) { - userDataCleanableList.forEach { it.resetForUser(userId.toLong()) } + userDataCleanableList().forEach { it.resetForUser(userId.toLong()) } userDao.deleteUserById(userId) } } 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 new file mode 100644 index 000000000..4fe71e42a --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManager.kt @@ -0,0 +1,72 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.auth.backup + +import com.infomaniak.core.auth.DerivedTokenGenerator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import splitties.init.appCtx + +sealed class RestoreFromBackupManager { + + abstract val state: SharedFlow + + abstract suspend fun waitForRestorationCompletion(targetUserId: Int?) + + val shouldShowRestorationScreen: Flow by lazy { state.map { it != State.Settled }.distinctUntilChanged() } + + abstract fun registerRemoveUser(removeUser: suspend (id: Int) -> Unit) + + 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: DerivedTokenGenerator.Issue, + val retry: () -> Unit, + val giveUp: () -> Unit, + ) : State + } + + enum class RestorationMode { + /** Handled by [RestoreFromBackupManager], with token derivation. */ + TokenDerivation, + /** Handled externally (with passkeys). */ + External, + } + + companion object { + 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 new file mode 100644 index 000000000..f8907630d --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -0,0 +1,184 @@ +/* + * 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.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.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 +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.serialization.ExperimentalSerializationApi +import splitties.experimental.ExperimentalSplittiesApi + +internal class RestoreFromBackupManagerImpl( + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + private val mode: RestorationMode = RestorationMode.TokenDerivation, +) : 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, + clientId = clientId, + userAgent = HttpUtils.getUserAgent, + ) + } + + override val state: SharedFlow = flow { + 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) + + 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 tailrec suspend fun FlowCollector.restoreAccounts( + 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 { + 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) + + val issuesWithUser = coroutineScope { + usersToDeriveTokensFor.map { user -> + async { + when (val result = attemptRestoringAccount(user)) { + is Xor.First -> userDb.useWriterConnection { + it.immediateTransaction { + userDao.update(user.copy(apiToken = result.value)) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + } + null + } + is Xor.Second -> result.value to user + } + } + } + }.awaitAll().filterNotNull() + + if (issuesWithUser.isEmpty()) return + + val 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) { + val removeUser = removeUserDeferred.await() + issuesWithUser.forEach { (_, user) -> removeUser(user.id) } + return + } + restoreAccounts(currentAndroidId = currentAndroidId, allUsers = allUsers) + } + + 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/backup/RestoringFromBackupFailedScreen.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt new file mode 100644 index 000000000..397f52691 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -0,0 +1,118 @@ +/* + * 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.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.MaterialTheme +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.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( + 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 = modifier.padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.Bottom), + ) { + 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() + ) + } +} + +@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() { + RestoringFromBackupFailedScreen( + RestoreFromBackupManager.State.RestoringFromBackupFailed( + cause = DerivedTokenGenerator.Issue.OtherIssue(Exception()), + retry = {}, + giveUp = {} + )) +} 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 790418bf2..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 @@ -24,13 +24,17 @@ 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 @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> @@ -81,4 +85,15 @@ 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? + + @get:Query("SELECT * FROM TokenDeviceBinding") + val tokenDeviceBindings: Flow> + + @Query("SELECT * FROM TokenDeviceBinding WHERE userId=:userId") + fun tokenDeviceBinding(userId: Int): Flow } 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/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 + 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) } 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/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 4d45c302d..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 @@ -20,8 +20,12 @@ 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.auth.shouldReport import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.completableScope @@ -31,7 +35,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 @@ -317,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) { @@ -342,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) } 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) - } } 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 // ...