Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dccf866
chore: Move DerivedTokenGenerator to the Auth module
LouisCAD Jul 7, 2026
7cf6f9b
feat: Introduce the RestoreFromBackupManager interface
LouisCAD Jul 7, 2026
080c4fb
chore: Add suspending allUsers() in UserDao
LouisCAD Jul 8, 2026
f22ee36
feat: Add WIP RestoreFromBackupManagerImpl
LouisCAD Jul 8, 2026
1429687
chore: Forward issues from DerivedTokenGenerator in RestoreFromBackup…
LouisCAD Jul 9, 2026
fcdd650
chore: Store user token to device association in the db instead of a …
LouisCAD Jul 9, 2026
e721f28
chore: Move ensureRestorationIsHandled from impl to the sealed class
LouisCAD Jul 21, 2026
11790ab
chore: Add `shouldShowRestorationScreen` helper in RestoreFromBackupM…
LouisCAD Jul 21, 2026
fed73a7
feat: Auto retry post-restoration token derivation if appropriate
LouisCAD Jul 21, 2026
8bb4be1
fix: Use a getter to get the up-to-date userDataCleanableList
LouisCAD Jul 21, 2026
dc3600e
feat: Remove relevant users when the user gives up backup restoration
LouisCAD Jul 21, 2026
11b3482
docs: Update CrossAppLogin README.md
LouisCAD Jul 21, 2026
88ff757
fix: Fix crashes and race condition
LouisCAD Jul 21, 2026
3e3a236
feat: Add barebones RestoringFromBackupFailedScreen
LouisCAD Jul 21, 2026
9221f79
feat: Move landscape UI of backup restoration failed to the end
LouisCAD Jul 29, 2026
451e267
fix: Fix AccountUtils tests
LouisCAD Jul 29, 2026
799eddc
fix: Ensure attestation tokens cannot be reused
LouisCAD Jul 30, 2026
6e6e71a
Initial plan
Copilot Jul 30, 2026
dabb313
test: add RestoreFromBackupManagerImpl tests for data transfer support
Copilot Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
203 changes: 203 additions & 0 deletions Auth/schemas/com.infomaniak.core.auth.room.UserDatabase/9.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,9 +39,10 @@ import kotlinx.coroutines.flow.flowOf
*/
abstract class AbstractCurrentUserAccountUtils(
appContext: Context,
userDataCleanableList: List<AssociatedUserDataCleanable> = emptyList(),
userDataCleanableList: () -> List<AssociatedUserDataCleanable> = { 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]
Expand Down
13 changes: 11 additions & 2 deletions Auth/src/main/kotlin/com/infomaniak/core/auth/CredentialManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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<AssociatedUserDataCleanable>
) : UserAccountUtils(
appContext = appCtx,
userDataCleanableList = userDataCleanableList,
userDatabase = UserDatabase.instance,
) {

abstract val currentUserId: Int
abstract var currentUser: User?

fun getAllUsers(): LiveData<List<User>> = userDatabase.userDao().getAll()
fun getAllUsers(): LiveData<List<User>> = userDatabase.userDao().allAsLiveData()

fun getAllUsersCount(): Int = userDatabase.userDao().count()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
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 -> when (details.issue) {
is AppIntegrityIssue.RetryLater, is AppIntegrityIssue.Internal -> true
is AppIntegrityIssue.DeviceIssue, is AppIntegrityIssue.DevError, is AppIntegrityIssue.SuspiciousError -> false
}
is Issue.ErrorResponse -> true
is Issue.NetworkIssue -> true
is Issue.OtherIssue -> false
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<String>): Xor<ApiToken, Issue>
suspend fun isAppIntegrityGuaranteedToFail(): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,53 +15,39 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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.common.dynamicLazyMap
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import splitties.init.appCtx
import java.io.IOException

internal class DerivedTokenGeneratorImpl(
coroutineScope: CoroutineScope,
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,
) : DerivedTokenGenerator {

private val appIntegrityManager = AppIntegrityManager(appCtx, userAgent)

private val attestationTokensForUrls = coroutineScope.dynamicLazyMap<String, Deferred<Xor<String, Issue>>>(
cacheManager = { _, asyncResult ->
// TODO: Cache tokens again (forever only if possible) once they can be reused,
// by uncommenting, the line below, and possibly replacing awaitCancellation() if reuse is limited in time.
// if (asyncResult.await() is Xor.First) awaitCancellation()
// Skip cache if unsuccessful.
},
) { targetUrl -> async { attemptFetchNewAttestationToken(targetUrl) } }

override suspend fun attemptDerivingOneOfTheseTokens(tokensToTry: Set<String>): Xor<ApiToken, Issue> {
require(tokensToTry.isNotEmpty())

Expand All @@ -77,7 +63,7 @@ internal class DerivedTokenGeneratorImpl(

private suspend fun attemptDerivingToken(token: String): Xor<ApiToken, Issue> {
val targetUrl = tokenRetrievalUrl
val attestationToken: String = when (val result = attestationTokensForUrls.useElement(targetUrl) { it.await() }) {
val attestationToken: String = when (val result = attemptFetchNewAttestationToken(targetUrl)) {
is Xor.First -> result.value
is Xor.Second -> return result
}
Expand Down
Loading