diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index ea9f69978..308fa1e22 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -41,11 +41,16 @@ dependencies { implementation(project(":AppIntegrity")) implementation(project(":Network")) implementation(project(":Sentry")) + implementation(project(":Ui:Compose:Margin")) implementation(platform(core.compose.bom)) implementation(core.compose.ui) implementation(core.compose.runtime) + implementation(core.compose.ui) + implementation(core.compose.material3) implementation(core.activity.compose) + implementation(core.compose.ui.tooling.preview) + debugImplementation(core.compose.ui.tooling) implementation(core.appcompat) implementation(core.androidx.core.ktx) @@ -64,5 +69,6 @@ dependencies { testImplementation(core.junit) testImplementation(core.androidx.test.core) testImplementation(core.kotlinx.coroutines.test) + testImplementation(core.kotest.assertions) testImplementation(core.robolectric) } diff --git a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json new file mode 100644 index 000000000..c41246e5b --- /dev/null +++ b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/10.json @@ -0,0 +1,213 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6d4be66bdbd5459afb46f129e14738f7", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `firstname` TEXT NOT NULL, `lastname` TEXT NOT NULL, `email` TEXT NOT NULL, `avatar` TEXT, `phones` TEXT, `card` TEXT, `login` TEXT NOT NULL, `isStaff` INTEGER NOT NULL DEFAULT false, `organizations` TEXT NOT NULL, `preferences_security_score` INTEGER DEFAULT 0, `preferences_security_dateLastChangedPassword` INTEGER DEFAULT 0, `preferences_organizationPreference_currentOrganizationId` INTEGER NOT NULL DEFAULT 0, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `tokenType` TEXT NOT NULL, `expiresIn` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `scope` TEXT, `expiresAt` INTEGER, `isTemporary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "firstname", + "columnName": "firstname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastname", + "columnName": "lastname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT" + }, + { + "fieldPath": "phones", + "columnName": "phones", + "affinity": "TEXT" + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT" + }, + { + "fieldPath": "login", + "columnName": "login", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "organizations", + "columnName": "organizations", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preferences.security.score", + "columnName": "preferences_security_score", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "preferences.security.dateLastChangedPassword", + "columnName": "preferences_security_dateLastChangedPassword", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "preferences.organizationPreference.currentOrganizationId", + "columnName": "preferences_organizationPreference_currentOrganizationId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "apiToken.accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiToken.refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT" + }, + { + "fieldPath": "apiToken.tokenType", + "columnName": "tokenType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiToken.expiresIn", + "columnName": "expiresIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken.userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken.scope", + "columnName": "scope", + "affinity": "TEXT" + }, + { + "fieldPath": "apiToken.expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "apiToken.isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CurrentUserId", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `uniqueKey` TEXT NOT NULL, PRIMARY KEY(`uniqueKey`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "uniqueKey", + "columnName": "uniqueKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uniqueKey" + ] + } + }, + { + "tableName": "TokenDeviceBinding", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `androidId` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "androidId", + "columnName": "androidId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6d4be66bdbd5459afb46f129e14738f7')" + ] + } +} \ No newline at end of file diff --git a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json index 7e212989b..cb2334363 100644 --- a/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json +++ b/Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json @@ -173,4 +173,4 @@ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd2186f80b68b770c4f4081c454b43377')" ] } -} \ No newline at end of file +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/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 18a6bc956..b154e22c3 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/BaseCredentialManager.kt @@ -22,8 +22,8 @@ import androidx.collection.ArrayMap import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.auth.room.UserDatabase -import com.infomaniak.core.network.networking.HttpClientConfig import com.infomaniak.core.login.ApiToken +import com.infomaniak.core.network.networking.HttpClientConfig import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache @@ -125,7 +125,7 @@ abstract class BaseCredentialManager : UserExistenceChecker { private suspend fun getDefaultTokenInterceptorListener(userId: Int): TokenInterceptorListener { var user = userDatabase.userDao().findById(userId) - return object : TokenInterceptorListener { + return object : TokenInterceptorListener(dedicatedUserId = userId) { override suspend fun onRefreshTokenSuccess(apiToken: ApiToken) { setUserToken(user, apiToken) } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt index cb41aac74..7bd1a9bbb 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt @@ -19,7 +19,10 @@ package com.infomaniak.core.auth import androidx.lifecycle.LiveData import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.AssociatedUserDataCleanable import com.infomaniak.core.login.ApiToken +import splitties.init.appCtx /** * CredentialManager: Adds a currentUserId and currentUser management layer to [BaseCredentialManager] @@ -29,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..ac00da83a --- /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 +} + +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..57b5191e5 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 { +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 b43ab8dca..daa6be569 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..d45466eab --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImpl.kt @@ -0,0 +1,196 @@ +/* + * 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, + private val userDatabase: UserDatabase = UserDatabase.instance, + tokenGenerator: DerivedTokenGenerator? = null, +) : RestoreFromBackupManager() { + + private val userDao = userDatabase.userDao() + + private val removeUserDeferred = CompletableDeferred Unit>() + + private val derivedTokenGenerator: DerivedTokenGenerator by lazy { + tokenGenerator ?: 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) + } + + /** + * @param allUsers All users from the database. We compute this outside to avoid having to re-query the db on recursive calls. + */ + private tailrec suspend fun FlowCollector.restoreAccounts( + currentAndroidId: String, + allUsers: List, + ) { + if (allUsers.isEmpty()) return // Fast-path for the app not set up yet case. + + val usersToDeriveTokensFor: List = getUsersThatNeedTokenRotation(currentAndroidId, allUsers).ifEmpty { return } + emit(State.RestoringFromBackup) + + val issuesWithUser = attemptRestoringAccounts(usersToDeriveTokensFor, currentAndroidId).ifEmpty { 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 attemptRestoringAccounts( + usersToDeriveTokensFor: List, + currentAndroidId: String + ): List> = coroutineScope { + usersToDeriveTokensFor.map { user -> + async { + when (val result = attemptRestoringAccount(user)) { + is Xor.First -> userDatabase.useWriterConnection { + it.immediateTransaction { + userDao.update(user.copy(apiToken = result.value)) + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + } + null + } + is Xor.Second -> result.value to user + } + } + } + }.awaitAll().filterNotNull() + + private suspend fun attemptRestoringAccount(user: User): Xor { + return derivedTokenGenerator.attemptDerivingOneOfTheseTokens(setOf(user.apiToken.accessToken)).also { result -> + if (result !is Xor.Second) return@also + 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") + } + } + } + + /** + * @param allUsers All users from the database. We compute this outside to avoid having to re-query the db on recursive calls. + */ + private suspend fun getUsersThatNeedTokenRotation( + currentAndroidId: String, + allUsers: List + ): List = coroutineScope { + allUsers.map { user -> + async { + val currentBinding = userDao.getTokenDeviceBindingForUser(user.id) + when { + currentBinding == null -> { + userDao.upsertTokenDeviceBinding(TokenDeviceBinding(user.id, currentAndroidId)) + null // Adding missing valid binding (post app update). + } + currentBinding.androidId == currentAndroidId -> null // Already valid. + else -> user // Device changed. Need to derive token. + } + } + } + }.awaitAll().filterNotNull() +} + +private const val TAG = "RestoreFromBackupManagerImpl" 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..40572b58f --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/RestoringFromBackupFailedScreen.kt @@ -0,0 +1,119 @@ +/* + * 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.ui.compose.margin.Margin +import com.infomaniak.core.common.R as RCore + +@Composable +fun RestoringFromBackupFailedScreen( + state: RestoreFromBackupManager.State.RestoringFromBackupFailed, + modifier: Modifier = Modifier, +) = OrientationAwareContainer(modifier) { isLandscape -> + if (isLandscape) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(Margin.Medium, 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 = Margin.Giant), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Margin.Mini, alignment = Alignment.Bottom), + ) { + Text( + stringResource(R.string.accountRestoreFailedError), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(Margin.Mini)) + 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 OrientationAwareContainer( + 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 afee982e1..b104e6a3a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDao.kt @@ -24,6 +24,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update import androidx.room.Upsert +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.User import kotlinx.coroutines.flow.Flow @@ -31,7 +32,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> @@ -85,4 +89,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 b3aa67693..708044a40 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/room/UserDatabase.kt @@ -30,13 +30,14 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.infomaniak.core.auth.models.CurrentUserId import com.infomaniak.core.auth.models.OrganizationAccount +import com.infomaniak.core.auth.models.TokenDeviceBinding import com.infomaniak.core.auth.models.user.Card import com.infomaniak.core.auth.models.user.Phone import com.infomaniak.core.auth.models.user.User import splitties.init.appCtx @Database( - entities = [User::class, CurrentUserId::class], + entities = [User::class, CurrentUserId::class, TokenDeviceBinding::class], autoMigrations = [ AutoMigration( from = 1, to = 2, @@ -55,11 +56,11 @@ import splitties.init.appCtx ), AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), ], - version = 9, + version = 10, exportSchema = true ) - @TypeConverters(UserConverter::class) abstract class UserDatabase internal constructor() : RoomDatabase() { diff --git a/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..291639c7d 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, + testAndroidId + ) } protected fun userOf(id: Int): User { @@ -55,4 +61,8 @@ abstract class BaseAccountUtilsTest { organizations = ArrayList(), ) } + + protected companion object { + const val testAndroidId = "test_android_id" + } } 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/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt new file mode 100644 index 000000000..37dac4491 --- /dev/null +++ b/Auth/src/test/java/com/infomaniak/core/auth/backup/RestoreFromBackupManagerImplTest.kt @@ -0,0 +1,320 @@ +/* + * 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:Suppress("NonAsciiCharacters") + +package com.infomaniak.core.auth.backup + +import com.infomaniak.core.auth.BaseAccountUtilsTest +import com.infomaniak.core.auth.DerivedTokenGenerator +import com.infomaniak.core.auth.backup.RestoreFromBackupManager.State +import com.infomaniak.core.auth.models.TokenDeviceBinding +import com.infomaniak.core.auth.room.UserDao +import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.Xor +import com.infomaniak.core.login.ApiToken +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Tests for [RestoreFromBackupManagerImpl], covering all paths in the restoration state machine: + * - Unchanged device (no restoration needed) + * - Pre-v9 missing binding (binding added, no restoration) + * - Transferred device with successful derivation + * - Multiple accounts (all same device / all transferred / mixed) + * - Partial failures + * - Retry after failure + * - Give-up after failure (failed users removed) + */ +class RestoreFromBackupManagerImplTest : BaseAccountUtilsTest() { + + private val currentAndroidId: String = testAndroidId + private val otherDeviceAndroidId = "other_device_id" + + // --------------------------------------------------- no users + + @Test + fun `no users → settles immediately`() = test { _ -> + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.Settled) + } + + // --------------------------------------------------- same device + + @Test + fun `same device → settles immediately`() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.Settled) + userDao.findById(1).shouldNotBeNull() + } + + @Test + fun `multiple users, all same device → settles immediately`() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + userDao.insertUserWithBinding(userId = 2, androidId = currentAndroidId) + userDao.insertUserWithBinding(userId = 3, androidId = currentAndroidId) + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.Settled) + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldNotBeNull() + userDao.findById(3).shouldNotBeNull() + } + + // --------------------------------------------------- pre-v9: missing binding + + @Test + fun `missing binding → adds binding and settles`() = test { userDao -> + userDao.insertUserWithoutBinding(userId = 1) + + val states = manager.collectStatesUntilSettled() + + // Missing binding means a same-device app upgrade (pre-v9); no token derivation needed. + states shouldBe listOf(State.Settled) + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.findById(1).shouldNotBeNull() + } + + @Test + fun `multiple users, all missing bindings → adds bindings and settles`() = test { userDao -> + userDao.insertUserWithoutBinding(userId = 1) + userDao.insertUserWithoutBinding(userId = 2) + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.Settled) + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldNotBeNull() + } + + // --------------------------------------------------- transferred device – success + + @Test + fun `transferred device, derivation succeeds → updates token and settles`() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + userDao.findById(1)?.apiToken?.accessToken shouldBe "derived_token" + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + } + + @Test + fun `multiple users, all transferred, all succeed → settles`() = test(timeout = 2.seconds) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + } + + @Test + fun `multiple users, mixed devices → only transferred are restored`() = test { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) // already valid + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) // needs restoration + + val states = manager.collectStatesUntilSettled() + + states shouldBe listOf(State.RestoringFromBackup, State.Settled) + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + userDao.getTokenDeviceBindingForUser(2)?.androidId shouldBe currentAndroidId + } + + // --------------------------------------------------- restoration failure + + @Test + fun `transferred device, derivation fails → emits RestoringFromBackupFailed state`() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val states = manager.collectStatesUntilSettled { failedState -> + userDao.findById(1).shouldNotBeNull() + failedState.giveUp() // unblock the flow so the test completes + } + + states.any { it is State.RestoringFromBackupFailed }.shouldBeTrue() + val failed = states.filterIsInstance().first() + failed.cause.shouldBeInstanceOf() + userDao.findById(1).shouldBeNull() + } + + // --------------------------------------------------- retry + + @Test + fun `transferred device, derivation fails, then retry → succeeds`() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + val states = manager.collectStatesUntilSettled { failedState -> + // Switch to success on retry. + derivedTokenGenerator.result = Xor.First(newToken("derived_after_retry")) + failedState.retry() + } + + // Attempt 1 → failure → retry → success → Settled + states.any { it is State.RestoringFromBackup }.shouldBeTrue() + states.count { it is State.RestoringFromBackupFailed } shouldBe 1 + states.last().shouldBeInstanceOf() + // Confirm the retry produced the new token and updated the binding. + userDao.findById(1)?.apiToken?.accessToken shouldBe "derived_after_retry" + userDao.getTokenDeviceBindingForUser(1)?.androidId shouldBe currentAndroidId + } + + // --------------------------------------------------- give-up + + @Test + fun `transferred device, derivation fails, give up → removes failed user and settles`() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + userDao.insertUserWithBinding(userId = 1, androidId = otherDeviceAndroidId) + + manager.collectStatesUntilSettled { failedState -> + userDao.findById(1).shouldNotBeNull() + failedState.giveUp() + } + + userDao.findById(1).shouldBeNull() + } + + @Test + fun `multiple users, partial failure, give up → only removes failed user`() = test( + initialTokenDerivationResult = networkFailure() + ) { userDao -> + // User 1 is on the current device: no restoration required, must not be removed. + userDao.insertUserWithBinding(userId = 1, androidId = currentAndroidId) + // User 2 was transferred: restoration will fail. + userDao.insertUserWithBinding(userId = 2, androidId = otherDeviceAndroidId) + + manager.collectStatesUntilSettled { failedState -> + userDao.findById(2).shouldNotBeNull() + failedState.giveUp() + } + + // Only the user whose restoration failed must be removed. + userDao.findById(1).shouldNotBeNull() + userDao.findById(2).shouldBeNull() + } + + // ------------------------------------------------------------------ helpers + + private fun test( + timeout: Duration = .1.seconds, + initialTokenDerivationResult: Xor = Xor.First(newToken("derived_token")), + block: suspend RestoreFromBackupTestScope.(userDao: UserDao) -> Unit + ) { + runTest(timeout = timeout) { + val userDatabase = UserDatabase.instantiateDataBase(context, inMemory = true) + val restoreFromBackupTestScope = RestoreFromBackupTestScope( + testScope = this, + userDatabase = userDatabase, + initialTokenDerivationResult = initialTokenDerivationResult, + ) + try { + restoreFromBackupTestScope.block(userDatabase.userDao()) + } finally { + userDatabase.close() + } + } + } + + /** Inserts a user plus a [TokenDeviceBinding] recording which device created the token. */ + private suspend fun UserDao.insertUserWithBinding(userId: Int, androidId: String) { + insert(userOf(userId)) + upsertTokenDeviceBinding(TokenDeviceBinding(userId, androidId)) + } + + /** Inserts a user with **no** [TokenDeviceBinding] – simulates a pre-v9 database row. */ + private suspend fun UserDao.insertUserWithoutBinding(userId: Int) { + insert(userOf(userId)) + } + + /** + * Collects [State] values emitted by [state] until [State.Settled], + * returning the full sequence (inclusive). Any [State.RestoringFromBackupFailed] encountered + * is forwarded to [onFailed] before the next state is awaited. + */ + private suspend fun RestoreFromBackupManager.collectStatesUntilSettled( + onFailed: suspend (State.RestoringFromBackupFailed) -> Unit = {}, + ): List { + val states = mutableListOf() + state.first { s -> + states += s + if (s is State.RestoringFromBackupFailed) onFailed(s) + s is State.Settled + } + return states + } + + private class FakeDerivedTokenGenerator( + var result: Xor = Xor.First( + ApiToken(accessToken = "new_token", tokenType = "Bearer", userId = 0, expiresIn = 3600) + ), + ) : DerivedTokenGenerator { + override suspend fun attemptDerivingOneOfTheseTokens( + tokensToTry: Set, + ): Xor = result + + override suspend fun isAppIntegrityGuaranteedToFail() = false + } + + private class RestoreFromBackupTestScope( + testScope: TestScope, + userDatabase: UserDatabase, + initialTokenDerivationResult: Xor, + ) { + val derivedTokenGenerator = FakeDerivedTokenGenerator(initialTokenDerivationResult) + val manager: RestoreFromBackupManager by lazy { + RestoreFromBackupManagerImpl( + coroutineScope = testScope.backgroundScope, + userDatabase = userDatabase, + tokenGenerator = derivedTokenGenerator + ).also { + it.registerRemoveUser { id -> userDatabase.userDao().deleteUserById(id) } + } + } + } +} + +private fun newToken(accessToken: String = "new_token"): ApiToken = + ApiToken(accessToken = accessToken, tokenType = "Bearer", userId = 0, expiresIn = 3600) + +private fun networkFailure(): Xor = + Xor.Second(DerivedTokenGenerator.Issue.NetworkIssue(IOException("network error"))) 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/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt new file mode 100644 index 000000000..d7119b66e --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/BackupDataOutput.kt @@ -0,0 +1,29 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataOutput +import android.os.Build.VERSION.SDK_INT +import splitties.bitflags.hasFlag + +internal val BackupDataOutput.isDeviceToDeviceTransfer: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else null + +internal val BackupDataOutput.isClientSideEncryptionEnabled: Boolean? + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else null diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt new file mode 100644 index 000000000..64c8124db --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackup.kt @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgent +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.util.Log +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException + +/** + * Copied from `android.app.backup.FullBackup`, then converted to Kotlin so we don't need to enable Java compilation. + * + * + * Global constant definitions et cetera related to the full-backup-to-fd + * binary format. Nothing in this namespace is part of any API; it's all + * hidden details of the current implementation gathered into one location. + * + */ +// Ignore warnings from code copied from AOSP. +object FullBackup { + const val TAG: String = "FullBackup" + + /** + * Copy data from a socket to the given File location on permanent storage. The + * modification time and access mode of the resulting file will be set if desired, + * although group/all rwx modes will be stripped: the restored file will not be + * accessible from outside the target application even if the original file was. + * If the `type` parameter indicates that the result should be a directory, + * the socket parameter may be `null`; even if it is valid, no data will be + * read from it in this case. + * + * + * If the `mode` argument is negative, then the resulting output file will not + * have its access mode or last modification time reset as part of this operation. + * + * @param data Socket supplying the data to be copied to the output file. If the + * output is a directory, this may be `null`. + * @param size Number of bytes of data to copy from the socket to the file. At least + * this much data must be available through the `data` parameter. + * @param type Must be either [BackupAgent.TYPE_FILE] for ordinary file data + * or [BackupAgent.TYPE_DIRECTORY] for a directory. + * @param mode Unix-style file mode (as used by the chmod(2) syscall) to be set on + * the output file or directory. group/all rwx modes are stripped even if set + * in this parameter. If this parameter is negative then neither + * the mode nor the mtime values will be applied to the restored file. + * @param mtime A timestamp in the standard Unix epoch that will be imposed as the + * last modification time of the output file. if the `mode` parameter is + * negative then this parameter will be ignored. + * @param outFile Location within the filesystem to place the data. This must point + * to a location that is writeable by the caller, preferably using an absolute path. + * @throws IOException + */ + @Throws(IOException::class) + fun restoreFile( + data: ParcelFileDescriptor, + size: Long, + type: Int, + mode: Long, + mtime: Long, + outFile: File? + ) { + var size = size + var mode = mode + if (type == BackupAgent.TYPE_DIRECTORY) { + // Canonically a directory has no associated content, so we don't need to read + // anything from the pipe in this case. Just create the directory here and + // drop down to the final metadata adjustment. + outFile?.mkdirs() + } else { + var out: FileOutputStream? = null + + // Pull the data from the pipe, copying it to the output file, until we're done + try { + if (outFile != null) { + val parent = outFile.getParentFile() + if (!parent!!.exists()) { + // in practice this will only be for the default semantic directories, + // and using the default mode for those is appropriate. + // This can also happen for the case where a parent directory has been + // excluded, but a file within that directory has been included. + parent.mkdirs() + } + out = FileOutputStream(outFile) + } + } catch (e: IOException) { + Log.e(TAG, "Unable to create/open file " + outFile!!.path, e) + } + + val buffer = ByteArray(64 * 1024) + val origSize = size + val `in` = FileInputStream(data.fileDescriptor) + while (size > 0) { + val toRead = if (size > buffer.size) buffer.size else size.toInt() + val got = `in`.read(buffer, 0, toRead) + if (got <= 0) { + Log.w( + TAG, ("Incomplete read: expected " + size + " but got " + + (origSize - size)) + ) + break + } + if (out != null) { + try { + out.write(buffer, 0, got) + } catch (e: IOException) { + // Problem writing to the file. Quit copying data and delete + // the file, but of course keep consuming the input stream. + Log.e(TAG, "Unable to write to file " + outFile!!.path, e) + out.close() + out = null + outFile.delete() + } + } + size -= got.toLong() + } + out?.close() + } + + // Now twiddle the state to match the backup, assuming all went well + if (mode >= 0 && outFile != null) { + try { + // explicitly prevent emplacement of files accessible by outside apps + mode = mode and 448L // 0700 in octal notation. + Os.chmod(outFile.path, mode.toInt()) + } catch (e: ErrnoException) { + throw IOException(e.message).also { it.initCause(e) } + } + outFile.setLastModified(mtime) + } + } +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt new file mode 100644 index 000000000..465ae9e5a --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupAgent.kt @@ -0,0 +1,92 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.common.backup + +import android.R.attr.data +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.FullBackupDataOutput +import android.os.ParcelFileDescriptor +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream + +/** + * The Android Backup API design and documentation are very confusing, because there are 2 different systems, + * not clearly stated in the relevant methods, so here's a summary of each system: + * + * ## 1. Key/value backup + * + * This is a legacy system that gives a lot of flexibility, incremental backups… but only 5MB of storage, + * and a very confusing API, mainly because of its documentation that lacks clarity about what each function is and should do. + * + * ## 2. Full backup / AutoBackup + * + * This is also very poorly documented, and the relation between the programmatic APIs and the 2 different XML syntaxes + * is documented in a very unclear way. + * + * Anyway, this API is the one to use, with XML rules, and with overrides of [onFullBackup] and [onRestoreFile] for specific + * cases, like extracting some data from a DB that shouldn't be fully backed-up, as a temporary file that is staged for backup. + */ +abstract class FullBackupAgent : BackupAgent() { + + /** + * Helper function to read the right amount of bytes from [data] directly into a ByteArray. + * Designed to be used in [onRestoreFile] overrides. + */ + protected fun ParcelFileDescriptor.toByteArray(size: Long): ByteArray { + val sizeInBytes = size.toInt() + check(sizeInBytes.toLong() == size) + return ByteArray(sizeInBytes).also { destination -> + val readyBytesCount = DataInputStream(FileInputStream(fileDescriptor)).read(destination) + check(readyBytesCount == destination.size) + } + } + + @Suppress("RedundantOverride") // Allows specifying the nullability. + override fun onFullBackup(data: FullBackupDataOutput) { + super.onFullBackup(data) + } + + @Suppress("RedundantOverride") // Allows specifying the nullability. + override fun onRestoreFile( + data: ParcelFileDescriptor, + size: Long, + destination: File, + type: Int, + mode: Long, + mtime: Long + ) { + super.onRestoreFile(data, size, destination, type, mode, mtime) + } + + // Never called for full backup. + final override fun onBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput?, + newState: ParcelFileDescriptor? + ) = Unit + + // Never called for full backup. + final override fun onRestore( + data: BackupDataInput?, + appVersionCode: Int, + newState: ParcelFileDescriptor? + ) = Unit +} diff --git a/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt new file mode 100644 index 000000000..2c2a0e9e5 --- /dev/null +++ b/Common/src/main/kotlin/com/infomaniak/core/common/backup/FullBackupDataOutput.kt @@ -0,0 +1,36 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.common.backup + +import android.app.backup.BackupAgent +import android.app.backup.FullBackupDataOutput +import android.os.Build.VERSION.SDK_INT +import splitties.bitflags.hasFlag + +/** + * If true, we don't have the 25MB limit. + * + * Before API 28, we can't know if it's a device-to-device transfer, or a cloud backup. + * Since it's going through the same pipeline either way, and since the 25MB limit + * is applied in both cases, we consider that we're not in the device-to-device transfer case. + */ +val FullBackupDataOutput.isDeviceToDeviceTransfer: Boolean + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER) else false + +val FullBackupDataOutput.isClientSideEncryptionEnabled: Boolean + get() = if (SDK_INT >= 28) transportFlags.hasFlag(BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) else false diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 5bc7f9492..a3f0e527b 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -20,8 +20,13 @@ 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.auth.shouldRetryAutomatically import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.completableScope @@ -31,7 +36,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 @@ -189,7 +193,7 @@ internal class CrossAppLoginFacadeImpl( tokens.add(result.value) } is Xor.Second -> { - hadATerminalIssue = hadATerminalIssue || result.value !is Issue.NetworkIssue + hadATerminalIssue = hadATerminalIssue || !result.value.shouldRetryAutomatically() errorMessageIds.add(getTokenDerivationIssueErrorMessage(account, issue = result.value)) } } @@ -317,24 +321,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 +333,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 // ...