From a407386b72715b5cdd357610969b415b77cc40e0 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 17 Apr 2026 09:51:33 +0200 Subject: [PATCH 01/30] chore: Change backup configuration to include and exclude what we need --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/res/xml/backup_rules.xml | 37 +++++++++++----- .../main/res/xml/data_extraction_rules.xml | 43 ++++++++++++------- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5f6ebc67..7fabe4de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ + ~ Infomaniak Authenticator - 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 . + --> - - \ No newline at end of file + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b..1d9405a8 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,32 @@ + ~ Infomaniak Authenticator - 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 . + --> - - - - - \ No newline at end of file + + From f4fbadcf8efd2ff459455fd0bf7418af25c2b0ba Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 17 Apr 2026 09:52:06 +0200 Subject: [PATCH 02/30] chore: Utils method to manipulate files on each platforms --- .../kotlin/utils/FileUtils.android.kt | 44 ++++++++++ .../kotlin/internal/utils/FileUtils.kt | 35 ++++++++ .../appleMain/kotlin/utils/FileUtils.apple.kt | 84 +++++++++++++++++++ .../src/commonMain/kotlin/utils/FileUtils.kt | 26 ++++++ 4 files changed, 189 insertions(+) create mode 100644 multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt create mode 100644 multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt create mode 100644 multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt create mode 100644 multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt diff --git a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt new file mode 100644 index 00000000..9a27e5d4 --- /dev/null +++ b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt @@ -0,0 +1,44 @@ +/* + * Infomaniak Authenticator - 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.auth.lib.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.invoke +import splitties.init.appCtx +import java.io.File + +actual suspend fun createFolder(name: String) = Dispatchers.IO { + val folder = File(appCtx.filesDir, name) + if (!folder.exists()) { + folder.mkdirs() + } +} + +actual suspend fun createFileIn(folder: String, name: String): Unit = Dispatchers.IO { + val folder = File(appCtx.filesDir, folder) + File(folder, name).createNewFile() +} + +actual suspend fun checkFileExists(folder: String, name: String): Boolean = Dispatchers.IO { + val folder = File(appCtx.filesDir, folder) + File(folder, name).exists() +} + +actual suspend fun checkFolderExists(folder: String): Boolean = Dispatchers.IO { + File(appCtx.filesDir, folder).exists() +} diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt new file mode 100644 index 00000000..7d485e29 --- /dev/null +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt @@ -0,0 +1,35 @@ +/* + * Infomaniak Authenticator - 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.auth.lib.internal.utils + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +@OptIn(ExperimentalForeignApi::class) +internal fun getApplicationSupportDirectory(): String { + val directory = NSFileManager.defaultManager.URLForDirectory( + directory = NSApplicationSupportDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) + return requireNotNull(directory?.path) +} diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt new file mode 100644 index 00000000..b8b8881f --- /dev/null +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -0,0 +1,84 @@ +/* + * Infomaniak Authenticator - 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.auth.lib.utils + +import com.infomaniak.auth.lib.internal.utils.getApplicationSupportDirectory +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSFileManager +import platform.Foundation.NSNumber +import platform.Foundation.NSURL +import platform.Foundation.NSURLIsExcludedFromBackupKey +import platform.Foundation.numberWithBool + + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun createFolder(name: String) { + val basePath = getApplicationSupportDirectory() + val folderPath = "$basePath/$name" + + // Create the folder if it doesn't exist + NSFileManager.defaultManager.createDirectoryAtPath( + path = folderPath, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + + // Exclude folder from iCloud backup + val folderUrl = NSURL.fileURLWithPath(folderPath) + folderUrl.setResourceValue( + value = NSNumber.numberWithBool(true), + forKey = NSURLIsExcludedFromBackupKey, + error = null + ) +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun createFileIn(folder: String, name: String) { + val basePath = getApplicationSupportDirectory() + val folderPath = "$basePath/$folder" + + NSFileManager.defaultManager.createDirectoryAtPath( + path = folderPath, + withIntermediateDirectories = true, + attributes = null, + error = null + ) + + // Create empty file + NSFileManager.defaultManager.createFileAtPath( + path = "$folderPath/$name", + contents = null, + attributes = null + ) +} + +actual suspend fun checkFileExists(folder: String, name: String): Boolean { + val basePath = getApplicationSupportDirectory() + val filePath = "$basePath/$folder/$name" + + return NSFileManager.defaultManager.fileExistsAtPath(filePath) +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun checkFolderExists(folder: String): Boolean { + val basePath = getApplicationSupportDirectory() + val filePath = "$basePath/$folder" + + return NSFileManager.defaultManager.fileExistsAtPath(filePath) +} diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt new file mode 100644 index 00000000..9d3b2601 --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt @@ -0,0 +1,26 @@ +/* + * Infomaniak Authenticator - 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.auth.lib.utils + +expect suspend fun createFolder(name: String) + +expect suspend fun createFileIn(folder: String, name: String) + +expect suspend fun checkFileExists(folder: String, name: String): Boolean + +expect suspend fun checkFolderExists(folder: String): Boolean From 173aca5a408a31374ba59e9dc2f53477294fdbc9 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 17 Apr 2026 09:53:09 +0200 Subject: [PATCH 03/30] feat: Implements migration of accounts from backup # Conflicts: # multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt # multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt # multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt --- .../internal/KeyPairManagerImpl.android.kt | 15 ++++---- .../internal/KeyPairManagerImpl.apple.kt | 30 ++++++++-------- .../commonMain/kotlin/AuthenticatorFacade.kt | 1 + .../internal/AuthenticatorFacadeImpl.kt | 15 +++++--- .../commonMain/kotlin/internal/KeyManager.kt | 2 +- .../kotlin/internal/KeyPairManagerImpl.kt | 2 +- .../internal/managers/AuthenticatorManager.kt | 18 +++++++--- .../internal/managers/MigrationManager.kt | 36 +++++++++++++++++++ 8 files changed, 87 insertions(+), 32 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index 61f51e20..fa087b54 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -32,8 +32,8 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return Failure.KeyManagement.GenerationFailed(it.toString()) } - saveKeyToFilesDir("$userId-$keyId-private.key", keyPair.private.encoded) - saveKeyToFilesDir("$userId-$keyId-public.key", keyPair.public.encoded) + saveFileToFilesDir("$userId-$keyId-private.key", keyPair.private.encoded) + saveFileToFilesDir("$userId-$keyId-public.key", keyPair.public.encoded) return null } @@ -57,15 +57,16 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun findKeyIdFor(userId: Long): Xor { - val fileNamePrefix = "$userId-" + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor { val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { - it.name.startsWith(fileNamePrefix) + predicate(it.name) } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) - val keyId = userPassKey.name.substringAfter(fileNamePrefix).substringBefore('-') + val keyId = + userPassKey.name.substring(userPassKey.name.indexOfFirst { it == '-' } + 1, + userPassKey.name.indexOfLast { it == '-' }) return Xor.First(keyId) } @@ -81,7 +82,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { return Xor.First(Unit) } - private suspend fun saveKeyToFilesDir(fileName: String, key: ByteArray) = Dispatchers.IO { + private suspend fun saveFileToFilesDir(fileName: String, key: ByteArray) = Dispatchers.IO { val file = File(appCtx.filesDir, fileName) file.writeBytes(key) } diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 55e43ea4..4a775e7e 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,26 +126,26 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(userId: Long): Xor = memScoped { - //TODO[ik-auth]: Test this code somehow. - val userIdPrefix = "$userId-" - val (resultsArray, count) = getAllPrivateKeysQuery() + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor = + memScoped { + //TODO[ik-auth]: Test this code somehow. + val (resultsArray, count) = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) { - return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) - } + if (resultsArray == null || count == 0) { + return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) + } - for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + for (i in 0 until count) { + val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) - if (tag?.startsWith(userIdPrefix) == true) { - val keyId = tag.removePrefix(userIdPrefix) - return@memScoped Xor.First(keyId) + if (tag != null && predicate(tag)) { + val keyId = tag.substring(tag.indexOfFirst { it == '-' } + 1, tag.indexOfLast { it == '-' }) + return@memScoped Xor.First(keyId) + } } - } - Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for userId $userId")) - } + Xor.Second(Failure.KeyManagement.KeyNotFound("No key found matching $predicate")) + } actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor = memScoped { diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index c52dcb1f..60ca98b7 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -94,6 +94,7 @@ abstract class AuthenticatorFacade internal constructor() { accountsDatabase = accountsDatabase, authenticatorManager = authenticatorManager, webAuthnRepository = webAuthnRepository, + tokenBridge = tokenBridge, clientId = clientId, ) return AuthenticatorFacadeImpl( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 0d473760..dc67dbad 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -83,8 +83,10 @@ internal class AuthenticatorFacadeImpl( private val dao = accountsDatabase.getDao() private val accountEntities = flow { + val accountsFlow = dao.getAsFlow() + migrationManager.handleBackedUpAccounts(accountsFlow.first()) migrationManager.addLegacyAccountsToDB() - emitAll(dao.getAsFlow()) + emitAll(accountsFlow) }.shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) private val atLeastOneConnectedAccount: Flow = accountEntities.map { entities -> @@ -118,7 +120,12 @@ internal class AuthenticatorFacadeImpl( .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) override suspend fun addAccounts(connectedAccounts: List) { - val entities = connectedAccounts.map { it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) } + createFolder(name = "accountsInitialization") + + val entities = connectedAccounts.map { + createFileIn(folder = "accountsInitialization", name = it.id.toString()) + it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) + } dao.upsert(entities) } @@ -241,9 +248,9 @@ internal class AuthenticatorFacadeImpl( withRetries(userId = userId) { emit(Account.Status.NotConnected.AttemptingToConnect) if (!passKeyAlreadyRegistered) { - // TODO do that only if we don't need to use the backed up files + // Just in case orphans passkeys are lying around, we want to make sure to start from a clean state. authenticatorManager.deleteKeysFor(notRegisteredAccount.id) - authenticatorManager.registerPasskey(token, userId) + val _ = authenticatorManager.registerPasskey(token, userId) dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.FirstPasskeyAuthenticationPending)) } val token = authenticatorManager.getToken( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index 5b13a18d..b6eefbae 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -31,7 +31,7 @@ internal interface KeyPairManager { suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor - suspend fun findKeyIdFor(userId: Long): Xor + suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt index e1143b7b..b95d1834 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt @@ -27,6 +27,6 @@ internal expect class KeyPairManagerImpl() : KeyPairManager { keyId: String ): Xor - override suspend fun findKeyIdFor(userId: Long): Xor + override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 309ff98d..589d76e7 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -64,11 +64,17 @@ internal class AuthenticatorManager( ) webAuthnRepository.registerPasskey(token, registerPasskey) + + return keyIdAsString } - suspend fun getToken(clientId: String, userId: Long): Xor { - val keyId = keyPairManager.findKeyIdFor(userId).firstOrNull() - ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) + suspend fun getToken( + clientId: String, + userId: Long, + keyIdFromOldPasskey: String? = null, + ): Xor { + val keyId = keyIdFromOldPasskey ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() + ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() @@ -110,7 +116,7 @@ internal class AuthenticatorManager( } suspend fun removeAccount(token: String, userId: Long) { - val passkeyId = keyPairManager.findKeyIdFor(userId).firstOrNull() + val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it @@ -124,4 +130,8 @@ internal class AuthenticatorManager( suspend fun deleteKeysFor(userId: Long) { val _ = keyPairManager.deleteKeysMatching { it.startsWith("$userId-") } } + + suspend fun getKeyIdFor(userId: Long): String? { + return keyPairManager.findKeyIdFor({ it.startsWith("$userId-") }).firstOrNull() + } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index c297857d..e8c5e446 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -18,6 +18,7 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.MigrationAuthentication +import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable import com.infomaniak.auth.lib.internal.extensions.firstOrElse @@ -44,9 +45,44 @@ internal class MigrationManager( private val accountsDatabase: AccountsDatabase, private val authenticatorManager: AuthenticatorManager, private val webAuthnRepository: WebAuthnRepository, + private val tokenBridge: TokenBridge, private val clientId: String, ) { + private val dao = accountsDatabase.getDao() + + suspend fun handleBackedUpAccounts(accounts: List) { + delay(10_000) + println("Vincent => folder exists: ${checkFolderExists(folder = "accountsInitialization")}") + if (!checkFolderExists(folder = "accountsInitialization")) createFolder(name = "accountsInitialization") + accounts.filter { + checkFileExists(folder = "accountsInitialization", name = it.id.toString()).not() + }.forEach { account -> + println("Vincent => try to migrate account ${account.id}") + val keyId = authenticatorManager.getKeyIdFor(account.id) ?: return + // Get token with previous passkey + val token = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdFromOldPasskey = keyId, + ).firstOrNull()!! + // Register a new passkey + val newKeyId = authenticatorManager.registerPasskey(token, account.id) + // Getting a new token with the new passkey + val tokenWithNewPassKey = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdFromOldPasskey = newKeyId, + ).firstOrNull()!! + tokenBridge.persistTokenForAccount(account.id, tokenWithNewPassKey) + dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore + authenticatorManager.deleteKeysFor(account.id) + createFileIn(folder = "accountsInitialization", name = account.id.toString()) + println("Vincent => end migration ${account.id}") + } + } + suspend fun addLegacyAccountsToDB() { if (!needMigration()) return From 393cb2da2bc5212ce48962f469881d2e9a405224 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 17 Apr 2026 11:21:29 +0200 Subject: [PATCH 04/30] fix: Delete the old passkey when we successfully get a new token with the new passkey --- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index e8c5e446..35b35a94 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -52,13 +52,10 @@ internal class MigrationManager( private val dao = accountsDatabase.getDao() suspend fun handleBackedUpAccounts(accounts: List) { - delay(10_000) - println("Vincent => folder exists: ${checkFolderExists(folder = "accountsInitialization")}") if (!checkFolderExists(folder = "accountsInitialization")) createFolder(name = "accountsInitialization") accounts.filter { checkFileExists(folder = "accountsInitialization", name = it.id.toString()).not() }.forEach { account -> - println("Vincent => try to migrate account ${account.id}") val keyId = authenticatorManager.getKeyIdFor(account.id) ?: return // Get token with previous passkey val token = authenticatorManager.getToken( @@ -78,8 +75,8 @@ internal class MigrationManager( dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore authenticatorManager.deleteKeysFor(account.id) + webAuthnRepository.deletePasskey(tokenWithNewPassKey, keyId) createFileIn(folder = "accountsInitialization", name = account.id.toString()) - println("Vincent => end migration ${account.id}") } } From 6815822151f8c1bca422b5cda0fe83519bdecec2 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 17 Apr 2026 14:00:37 +0200 Subject: [PATCH 05/30] chore: Avoid having tokenBridge as parameter of MigrationManager --- .../src/commonMain/kotlin/AuthenticatorFacade.kt | 1 - .../commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt | 7 ++++++- .../kotlin/internal/managers/MigrationManager.kt | 8 +++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index 60ca98b7..c52dcb1f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -94,7 +94,6 @@ abstract class AuthenticatorFacade internal constructor() { accountsDatabase = accountsDatabase, authenticatorManager = authenticatorManager, webAuthnRepository = webAuthnRepository, - tokenBridge = tokenBridge, clientId = clientId, ) return AuthenticatorFacadeImpl( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index dc67dbad..5600d9e2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -84,7 +84,12 @@ internal class AuthenticatorFacadeImpl( private val accountEntities = flow { val accountsFlow = dao.getAsFlow() - migrationManager.handleBackedUpAccounts(accountsFlow.first()) + migrationManager.handleBackedUpAccounts( + accounts = accountsFlow.first(), + persistToken = { userId, token -> + tokenBridge.persistTokenForAccount(userId, token) + } + ) migrationManager.addLegacyAccountsToDB() emitAll(accountsFlow) }.shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 35b35a94..9e78a516 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -45,13 +45,15 @@ internal class MigrationManager( private val accountsDatabase: AccountsDatabase, private val authenticatorManager: AuthenticatorManager, private val webAuthnRepository: WebAuthnRepository, - private val tokenBridge: TokenBridge, private val clientId: String, ) { private val dao = accountsDatabase.getDao() - suspend fun handleBackedUpAccounts(accounts: List) { + suspend fun handleBackedUpAccounts( + accounts: List, + persistToken: suspend (userId: Long, token: String) -> Unit, + ) { if (!checkFolderExists(folder = "accountsInitialization")) createFolder(name = "accountsInitialization") accounts.filter { checkFileExists(folder = "accountsInitialization", name = it.id.toString()).not() @@ -71,7 +73,7 @@ internal class MigrationManager( userId = account.id, keyIdFromOldPasskey = newKeyId, ).firstOrNull()!! - tokenBridge.persistTokenForAccount(account.id, tokenWithNewPassKey) + persistToken(account.id, tokenWithNewPassKey) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore authenticatorManager.deleteKeysFor(account.id) From 6c29915a29dd719aace90663ad079233728b2825 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Thu, 23 Apr 2026 10:48:33 +0200 Subject: [PATCH 06/30] fix: Put methods to internal --- .../src/androidMain/kotlin/utils/FileUtils.android.kt | 8 ++++---- .../src/appleMain/kotlin/utils/FileUtils.apple.kt | 8 ++++---- .../src/commonMain/kotlin/utils/FileUtils.kt | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt index 9a27e5d4..efc625cd 100644 --- a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt @@ -22,23 +22,23 @@ import kotlinx.coroutines.invoke import splitties.init.appCtx import java.io.File -actual suspend fun createFolder(name: String) = Dispatchers.IO { +internal actual suspend fun createFolder(name: String) = Dispatchers.IO { val folder = File(appCtx.filesDir, name) if (!folder.exists()) { folder.mkdirs() } } -actual suspend fun createFileIn(folder: String, name: String): Unit = Dispatchers.IO { +internal actual suspend fun createFileIn(folder: String, name: String): Unit = Dispatchers.IO { val folder = File(appCtx.filesDir, folder) File(folder, name).createNewFile() } -actual suspend fun checkFileExists(folder: String, name: String): Boolean = Dispatchers.IO { +internal actual suspend fun checkFileExists(folder: String, name: String): Boolean = Dispatchers.IO { val folder = File(appCtx.filesDir, folder) File(folder, name).exists() } -actual suspend fun checkFolderExists(folder: String): Boolean = Dispatchers.IO { +internal actual suspend fun checkFolderExists(folder: String): Boolean = Dispatchers.IO { File(appCtx.filesDir, folder).exists() } diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index b8b8881f..43a1c903 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -27,7 +27,7 @@ import platform.Foundation.numberWithBool @OptIn(ExperimentalForeignApi::class) -actual suspend fun createFolder(name: String) { +internal actual suspend fun createFolder(name: String) { val basePath = getApplicationSupportDirectory() val folderPath = "$basePath/$name" @@ -49,7 +49,7 @@ actual suspend fun createFolder(name: String) { } @OptIn(ExperimentalForeignApi::class) -actual suspend fun createFileIn(folder: String, name: String) { +internal actual suspend fun createFileIn(folder: String, name: String) { val basePath = getApplicationSupportDirectory() val folderPath = "$basePath/$folder" @@ -68,7 +68,7 @@ actual suspend fun createFileIn(folder: String, name: String) { ) } -actual suspend fun checkFileExists(folder: String, name: String): Boolean { +internal actual suspend fun checkFileExists(folder: String, name: String): Boolean { val basePath = getApplicationSupportDirectory() val filePath = "$basePath/$folder/$name" @@ -76,7 +76,7 @@ actual suspend fun checkFileExists(folder: String, name: String): Boolean { } @OptIn(ExperimentalForeignApi::class) -actual suspend fun checkFolderExists(folder: String): Boolean { +internal actual suspend fun checkFolderExists(folder: String): Boolean { val basePath = getApplicationSupportDirectory() val filePath = "$basePath/$folder" diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt index 9d3b2601..0517009d 100644 --- a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt @@ -17,10 +17,10 @@ */ package com.infomaniak.auth.lib.utils -expect suspend fun createFolder(name: String) +internal expect suspend fun createFolder(name: String) -expect suspend fun createFileIn(folder: String, name: String) +internal expect suspend fun createFileIn(folder: String, name: String) -expect suspend fun checkFileExists(folder: String, name: String): Boolean +internal expect suspend fun checkFileExists(folder: String, name: String): Boolean -expect suspend fun checkFolderExists(folder: String): Boolean +internal expect suspend fun checkFolderExists(folder: String): Boolean From 91d93f30ba4da5a1e6862afb8e9185b997dbde75 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 24 Apr 2026 13:48:24 +0200 Subject: [PATCH 07/30] chore: Create a new Account status to migrate accounts and avoid creating two sets of files to handle migration account by account --- app/src/main/res/xml/backup_rules.xml | 2 +- .../main/res/xml/data_extraction_rules.xml | 2 +- .../kotlin/utils/FileUtils.android.kt | 24 ++---- .../appleMain/kotlin/utils/FileUtils.apple.kt | 57 ++------------ .../internal/AuthenticatorFacadeImpl.kt | 37 ++++----- .../kotlin/internal/db/AccountEntity.kt | 3 +- .../kotlin/internal/db/AccountsDao.kt | 7 +- .../internal/managers/AuthenticatorManager.kt | 2 +- .../internal/managers/MigrationManager.kt | 78 ++++++++++++------- .../internal/network/ApiClientProvider.kt | 4 +- .../kotlin/internal/network/utils/ApiExt.kt | 4 +- .../repositories/AccountsRepository.kt | 2 +- .../kotlin/network/exceptions/ApiException.kt | 2 +- .../network/exceptions/NetworkException.kt | 2 +- .../src/commonMain/kotlin/utils/FileUtils.kt | 8 +- 15 files changed, 98 insertions(+), 136 deletions(-) diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 6076b08d..3c27a914 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -24,5 +24,5 @@ path="." /> + path="Quoi ?" /> diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 1d9405a8..04a942a2 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -26,7 +26,7 @@ path="." /> + path="Quoi ?" /> diff --git a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt index efc625cd..8fa4ed53 100644 --- a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt @@ -22,23 +22,13 @@ import kotlinx.coroutines.invoke import splitties.init.appCtx import java.io.File -internal actual suspend fun createFolder(name: String) = Dispatchers.IO { - val folder = File(appCtx.filesDir, name) - if (!folder.exists()) { - folder.mkdirs() - } -} - -internal actual suspend fun createFileIn(folder: String, name: String): Unit = Dispatchers.IO { - val folder = File(appCtx.filesDir, folder) - File(folder, name).createNewFile() +actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { + File(appCtx.filesDir, name).exists() } -internal actual suspend fun checkFileExists(folder: String, name: String): Boolean = Dispatchers.IO { - val folder = File(appCtx.filesDir, folder) - File(folder, name).exists() -} - -internal actual suspend fun checkFolderExists(folder: String): Boolean = Dispatchers.IO { - File(appCtx.filesDir, folder).exists() +actual suspend fun createFile(name: String, content: String) { + File(appCtx.filesDir, name).apply { + createNewFile() + writeText(content) + } } diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index 43a1c903..990a56ed 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -17,68 +17,25 @@ */ package com.infomaniak.auth.lib.utils +import com.infomaniak.auth.lib.internal.extensions.toNsData import com.infomaniak.auth.lib.internal.utils.getApplicationSupportDirectory import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSFileManager -import platform.Foundation.NSNumber -import platform.Foundation.NSURL -import platform.Foundation.NSURLIsExcludedFromBackupKey -import platform.Foundation.numberWithBool - -@OptIn(ExperimentalForeignApi::class) -internal actual suspend fun createFolder(name: String) { +actual suspend fun checkFileExists(name: String): Boolean { val basePath = getApplicationSupportDirectory() - val folderPath = "$basePath/$name" + val filePath = "$basePath/$name" - // Create the folder if it doesn't exist - NSFileManager.defaultManager.createDirectoryAtPath( - path = folderPath, - withIntermediateDirectories = true, - attributes = null, - error = null, - ) - - // Exclude folder from iCloud backup - val folderUrl = NSURL.fileURLWithPath(folderPath) - folderUrl.setResourceValue( - value = NSNumber.numberWithBool(true), - forKey = NSURLIsExcludedFromBackupKey, - error = null - ) + return NSFileManager.defaultManager.fileExistsAtPath(filePath) } @OptIn(ExperimentalForeignApi::class) -internal actual suspend fun createFileIn(folder: String, name: String) { +actual suspend fun createFile(name: String, content: String) { val basePath = getApplicationSupportDirectory() - val folderPath = "$basePath/$folder" - - NSFileManager.defaultManager.createDirectoryAtPath( - path = folderPath, - withIntermediateDirectories = true, - attributes = null, - error = null - ) - // Create empty file NSFileManager.defaultManager.createFileAtPath( - path = "$folderPath/$name", - contents = null, + path = "$basePath/$name", + contents = content.toNsData(), attributes = null ) } - -internal actual suspend fun checkFileExists(folder: String, name: String): Boolean { - val basePath = getApplicationSupportDirectory() - val filePath = "$basePath/$folder/$name" - - return NSFileManager.defaultManager.fileExistsAtPath(filePath) -} - -@OptIn(ExperimentalForeignApi::class) -internal actual suspend fun checkFolderExists(folder: String): Boolean { - val basePath = getApplicationSupportDirectory() - val filePath = "$basePath/$folder" - - return NSFileManager.defaultManager.fileExistsAtPath(filePath) -} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5600d9e2..26bfdea9 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -39,6 +39,7 @@ import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull +import com.infomaniak.auth.lib.network.exceptions.ApiException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import kotlinx.coroutines.CompletableDeferred @@ -67,7 +68,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.io.IOException -import network.exceptions.ApiException import kotlin.time.Duration.Companion.seconds internal class AuthenticatorFacadeImpl( @@ -83,15 +83,9 @@ internal class AuthenticatorFacadeImpl( private val dao = accountsDatabase.getDao() private val accountEntities = flow { - val accountsFlow = dao.getAsFlow() - migrationManager.handleBackedUpAccounts( - accounts = accountsFlow.first(), - persistToken = { userId, token -> - tokenBridge.persistTokenForAccount(userId, token) - } - ) + migrationManager.setBackedUpAccountsStatus() migrationManager.addLegacyAccountsToDB() - emitAll(accountsFlow) + emitAll(dao.getAccountsAsFlow()) }.shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) private val atLeastOneConnectedAccount: Flow = accountEntities.map { entities -> @@ -125,10 +119,7 @@ internal class AuthenticatorFacadeImpl( .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) override suspend fun addAccounts(connectedAccounts: List) { - createFolder(name = "accountsInitialization") - val entities = connectedAccounts.map { - createFileIn(folder = "accountsInitialization", name = it.id.toString()) it.toEntity(AccountEntity.Status.PasskeyRegistrationPending) } dao.upsert(entities) @@ -220,16 +211,22 @@ internal class AuthenticatorFacadeImpl( emitAll(appStatusFlow) }.distinctUntilChanged() - private fun loginAttemptsFlow(userId: Long): Flow = dao.get(userId).transformLatest { entity -> - emit(null) - when (entity?.status) { - AccountEntity.Status.ToBeMigrated -> migrationAttempts(entity) - AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { - registrationAttempts(entity) + private fun loginAttemptsFlow(userId: Long): Flow = + dao.getAccountAsFlow(userId).transformLatest { entity -> + emit(null) + when (entity?.status) { + AccountEntity.Status.ToBeMigrated -> migrationAttempts(entity) + AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending -> { + registrationAttempts(entity) + } + AccountEntity.Status.RestoringFromBackup -> { + migrationManager.restore(account = entity) { userId, token -> + authenticatorBridge.persistTokenForAccount(userId, token) + } + } + AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } - AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } - } private suspend fun shouldTryImmediateLogin(): Boolean = raceOf( { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 77da3936..d84c1177 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -32,7 +32,6 @@ internal data class AccountEntity( ) { val isLoggedIn: Boolean get() = status == Status.LoggedIn - enum class Status { /* @@ -55,5 +54,7 @@ internal data class AccountEntity( /** Account successfully connected, with registered passkey. */ LoggedIn, + + RestoringFromBackup, } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 378d4774..07ca935c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -27,10 +27,13 @@ import kotlinx.coroutines.flow.Flow internal interface AccountsDao { @Query("SELECT * FROM AccountEntity") - fun getAsFlow(): Flow> + fun getAccountsAsFlow(): Flow> + + @Query("SELECT * FROM AccountEntity WHERE status = :status") + fun getAccountsWith(status: AccountEntity.Status): List @Query("SELECT * FROM AccountEntity WHERE id = :id") - fun get(id: Long): Flow + fun getAccountAsFlow(id: Long): Flow @Upsert suspend fun upsert(account: AccountEntity) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 589d76e7..3e1266e4 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -44,7 +44,7 @@ internal class AuthenticatorManager( suspend fun getUserProfile(token: String) = webAuthnRepository.getUserProfile(token) - suspend fun registerPasskey(token: String, userId: Long) { + suspend fun registerPasskey(token: String, userId: Long): String { val passkeysOptions = webAuthnRepository.getPasskeysOptions(token).data val keyIds = cryptoObjectsBuilder.getKeyIds() val keyIdAsByteArray = keyIds.first diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 9e78a516..e16deef0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -33,10 +33,12 @@ import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.models.migration.ApiToken +import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.utils.checkFileExists +import com.infomaniak.auth.lib.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException -import network.exceptions.ApiException import org.kotlincrypto.macs.hmac.sha2.HmacSHA256 import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -50,38 +52,38 @@ internal class MigrationManager( private val dao = accountsDatabase.getDao() - suspend fun handleBackedUpAccounts( - accounts: List, - persistToken: suspend (userId: Long, token: String) -> Unit, - ) { - if (!checkFolderExists(folder = "accountsInitialization")) createFolder(name = "accountsInitialization") - accounts.filter { - checkFileExists(folder = "accountsInitialization", name = it.id.toString()).not() - }.forEach { account -> - val keyId = authenticatorManager.getKeyIdFor(account.id) ?: return - // Get token with previous passkey - val token = authenticatorManager.getToken( - clientId = clientId, - userId = account.id, - keyIdFromOldPasskey = keyId, - ).firstOrNull()!! - // Register a new passkey - val newKeyId = authenticatorManager.registerPasskey(token, account.id) - // Getting a new token with the new passkey - val tokenWithNewPassKey = authenticatorManager.getToken( - clientId = clientId, - userId = account.id, - keyIdFromOldPasskey = newKeyId, - ).firstOrNull()!! - persistToken(account.id, tokenWithNewPassKey) - dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) - // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore - authenticatorManager.deleteKeysFor(account.id) - webAuthnRepository.deletePasskey(tokenWithNewPassKey, keyId) - createFileIn(folder = "accountsInitialization", name = account.id.toString()) + suspend fun setBackedUpAccountsStatus() { + if (!doesAccountInitializationFileExist()) { + dao.getAccountsWith(AccountEntity.Status.LoggedIn).forEach { + dao.upsert(it.copy(status = AccountEntity.Status.RestoringFromBackup)) + } + createAccountInitializationFile() } } + suspend fun restore(account: AccountEntity, persistToken: suspend (userId: Long, token: String) -> Unit) { + val keyId = authenticatorManager.getKeyIdFor(account.id) ?: return + // Get token with previous passkey + val token = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdFromOldPasskey = keyId, + ).firstOrNull()!! + // Register a new passkey + val newKeyId = authenticatorManager.registerPasskey(token, account.id) + // Getting a new token with the new passkey + val tokenWithNewPassKey = authenticatorManager.getToken( + clientId = clientId, + userId = account.id, + keyIdFromOldPasskey = newKeyId, + ).firstOrNull()!! + persistToken(account.id, tokenWithNewPassKey) + dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore + authenticatorManager.deleteKeysFor(account.id) + webAuthnRepository.deletePasskey(tokenWithNewPassKey, keyId) + } + suspend fun addLegacyAccountsToDB() { if (!needMigration()) return @@ -176,4 +178,20 @@ internal class MigrationManager( scope = this.scope, ) } + + companion object { + private const val ACCOUNT_INITIALIZATION_FILE_NAME = "51756f69203f" + private const val ACCOUNT_INITIALIZATION_FILE_CONTENT = "466575722021" + + suspend fun doesAccountInitializationFileExist(): Boolean { + return checkFileExists(name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString()) + } + + suspend fun createAccountInitializationFile() { + createFile( + name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), + content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), + ) + } + } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index e57cc342..f9f9a59d 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt @@ -20,6 +20,8 @@ package com.infomaniak.auth.lib.internal.network import com.infomaniak.auth.lib.internal.models.ApiResponseForError import com.infomaniak.auth.lib.internal.network.utils.getHttpClientEngine import com.infomaniak.auth.lib.internal.network.utils.getRequestContextId +import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel @@ -43,8 +45,6 @@ import io.ktor.utils.io.CancellationException import kotlinx.io.IOException import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import network.exceptions.ApiException -import network.exceptions.NetworkException import kotlin.time.Duration.Companion.seconds internal class ApiClientProvider( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt index 9f0c1012..b09773bb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/utils/ApiExt.kt @@ -17,12 +17,12 @@ */ package com.infomaniak.auth.lib.internal.network.utils +import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.exceptions.UnknownException import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.utils.io.CancellationException -import network.exceptions.ApiException -import network.exceptions.NetworkException internal const val CONTENT_REQUEST_ID_HEADER = "x-request-id" diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt index 47988239..34f1158c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/AccountsRepository.kt @@ -23,7 +23,7 @@ import com.infomaniak.auth.lib.internal.db.AccountsDatabase internal class AccountsRepository(database: AccountsDatabase) { private val dao = database.getDao() - fun getAccounts() = dao.getAsFlow() + fun getAccounts() = dao.getAccountsAsFlow() suspend fun upsertAccount(account: AccountEntity) { dao.upsert(account) diff --git a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt index c99d5fe3..680ecd5f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/ApiException.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package network.exceptions +package com.infomaniak.auth.lib.network.exceptions /** * Parent class of API calls exception. diff --git a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt index b78ac853..23ff3bf3 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package network.exceptions +package com.infomaniak.auth.lib.network.exceptions /** * Thrown when a network-related error occurs, such as connectivity issues or timeouts. diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt index 0517009d..52bde963 100644 --- a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt @@ -17,10 +17,6 @@ */ package com.infomaniak.auth.lib.utils -internal expect suspend fun createFolder(name: String) +expect suspend fun createFile(name: String, content: String) -internal expect suspend fun createFileIn(folder: String, name: String) - -internal expect suspend fun checkFileExists(folder: String, name: String): Boolean - -internal expect suspend fun checkFolderExists(folder: String): Boolean +expect suspend fun checkFileExists(name: String): Boolean From dbecbc8d4fbc8f2ceb6ce9ab2570f7d890897ff3 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 24 Apr 2026 13:57:15 +0200 Subject: [PATCH 08/30] chore: Remove unused method --- .../commonMain/kotlin/models/migration/user/UserProfile.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt index 239a2bd4..d5827aa6 100644 --- a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt +++ b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/UserProfile.kt @@ -44,7 +44,4 @@ data class UserProfile( */ @Transient var apiToken: ApiToken = ApiToken(accessToken = "", tokenType = "", userId = 0), - - ) { - private fun String.firstOrEmpty(): String = if (isNotEmpty()) first().toString() else "" -} +) From fcc49d3db12e324a7d5eae919415718960e113f2 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 24 Apr 2026 13:59:53 +0200 Subject: [PATCH 09/30] chore: Clean code --- .../src/appleMain/kotlin/utils/FileUtils.apple.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index 990a56ed..f5f017e9 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -23,18 +23,13 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSFileManager actual suspend fun checkFileExists(name: String): Boolean { - val basePath = getApplicationSupportDirectory() - val filePath = "$basePath/$name" - - return NSFileManager.defaultManager.fileExistsAtPath(filePath) + return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") } @OptIn(ExperimentalForeignApi::class) actual suspend fun createFile(name: String, content: String) { - val basePath = getApplicationSupportDirectory() - NSFileManager.defaultManager.createFileAtPath( - path = "$basePath/$name", + path = "${getApplicationSupportDirectory()}/$name", contents = content.toNsData(), attributes = null ) From bb968898b751dbe2b4379267046da1774b489732 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Mon, 27 Apr 2026 11:15:17 +0200 Subject: [PATCH 10/30] fix: Use the right token when persistUser is called --- .../internal/AuthenticatorFacadeImpl.kt | 4 ++-- .../internal/managers/AuthenticatorManager.kt | 12 +++++++++-- .../internal/managers/MigrationManager.kt | 20 +++++++++++-------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 26bfdea9..5c13b0c1 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -134,7 +134,7 @@ internal class AuthenticatorFacadeImpl( val token = authenticatorManager.getToken(clientId, userId).firstOrElse { error("Could not get the key for user $userId from the storage: $it") } - authenticatorBridge.persistTokenForAccount(userId, token) + authenticatorBridge.persistTokenForAccount(userId, token.accessToken) } private fun accountsFlow( @@ -259,7 +259,7 @@ internal class AuthenticatorFacadeImpl( clientId = clientId, userId = userId, ).firstOrElse { error("Key not found: ${it.details}") } - authenticatorBridge.persistTokenForAccount(userId, token) + authenticatorBridge.persistTokenForAccount(userId, token.accessToken) dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.LoggedIn)) } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 3e1266e4..d919bec2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -28,6 +28,7 @@ import com.infomaniak.auth.lib.internal.repositories.AccountsRepository import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.internal.utils.SignUtils import com.infomaniak.auth.lib.internal.utils.Xor +import com.infomaniak.auth.lib.models.migration.ApiToken import io.ktor.utils.io.core.toByteArray import kotlinx.serialization.json.Json import okio.ByteString.Companion.toByteString @@ -72,7 +73,7 @@ internal class AuthenticatorManager( clientId: String, userId: Long, keyIdFromOldPasskey: String? = null, - ): Xor { + ): Xor { val keyId = keyIdFromOldPasskey ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) @@ -112,7 +113,14 @@ internal class AuthenticatorManager( clientExtensionResults = ClientExtensionResults, authenticatorAttachment = "platform", ) - return Xor.First(webAuthnRepository.verify(verifyAuthenticationData).accessToken) + val verifyAuthData = webAuthnRepository.verify(verifyAuthenticationData) + val apiToken = ApiToken( + accessToken = verifyAuthData.accessToken, + tokenType = verifyAuthData.tokenType, + userId = userId.toInt(), + scope = verifyAuthData.scope, + ) + return Xor.First(apiToken) } suspend fun removeAccount(token: String, userId: Long) { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index e16deef0..c0a04907 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -70,18 +70,18 @@ internal class MigrationManager( keyIdFromOldPasskey = keyId, ).firstOrNull()!! // Register a new passkey - val newKeyId = authenticatorManager.registerPasskey(token, account.id) + val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) // Getting a new token with the new passkey val tokenWithNewPassKey = authenticatorManager.getToken( clientId = clientId, userId = account.id, keyIdFromOldPasskey = newKeyId, ).firstOrNull()!! - persistToken(account.id, tokenWithNewPassKey) + persistToken(account.id, tokenWithNewPassKey.accessToken) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore authenticatorManager.deleteKeysFor(account.id) - webAuthnRepository.deletePasskey(tokenWithNewPassKey, keyId) + webAuthnRepository.deletePasskey(tokenWithNewPassKey.accessToken, keyId) } suspend fun addLegacyAccountsToDB() { @@ -110,7 +110,7 @@ internal class MigrationManager( deviceId = deviceId, userId = userId, ) - val tokenToUse = when (authentication) { + val temporaryToken = when (authentication) { is MigrationAuthentication.CrossAppLogin -> authentication.derivedToken else -> { val otp = getOtp(secret = secret, timestampSeconds = migrationOptions.timestamp) @@ -145,15 +145,19 @@ internal class MigrationManager( authenticatorManager.deleteKeysFor(userId) authenticatorManager.registerPasskey( - token = tokenToUse.accessToken, + token = temporaryToken.accessToken, userId = userId ) - val token = authenticatorManager.getToken( + val apiTokenFromPasskey = authenticatorManager.getToken( clientId = clientId, userId = userId, ).firstOrElse { error("Didn't find the key locally: $it") } - persistUser(tokenToUse) - webAuthnRepository.completeMigration(token = token, sessionId = migrationOptions.session, deviceId = deviceId) + persistUser(apiTokenFromPasskey) + webAuthnRepository.completeMigration( + token = apiTokenFromPasskey.accessToken, + sessionId = migrationOptions.session, + deviceId = deviceId + ) deleteLegacyAccount(userId.toString()) if (getLegacyAccounts().isEmpty()) deleteLegacyDB() From c519179bc97a8cbaa8e880cbdc1e9eb4c76d44fd Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Mon, 27 Apr 2026 11:15:34 +0200 Subject: [PATCH 11/30] chore: Improve how we update accounts when restoring from backup --- .../src/commonMain/kotlin/internal/db/AccountsDao.kt | 3 +++ .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 07ca935c..9265f3bf 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -41,6 +41,9 @@ internal interface AccountsDao { @Upsert suspend fun upsert(accounts: List) + @Query("UPDATE AccountEntity SET status = :newStatus WHERE status = :currentStatus") + suspend fun updateStatus(currentStatus: AccountEntity.Status, newStatus: AccountEntity.Status) + @Insert suspend fun insert(account: AccountEntity) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index c0a04907..8167a464 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -54,9 +54,7 @@ internal class MigrationManager( suspend fun setBackedUpAccountsStatus() { if (!doesAccountInitializationFileExist()) { - dao.getAccountsWith(AccountEntity.Status.LoggedIn).forEach { - dao.upsert(it.copy(status = AccountEntity.Status.RestoringFromBackup)) - } + dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) createAccountInitializationFile() } } From cde660fd4bd02511ac56a79ff7d573f95ce1d9e5 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Mon, 27 Apr 2026 15:16:48 +0200 Subject: [PATCH 12/30] chore: Do not include the file created on iOS in the backup --- .../src/appleMain/kotlin/utils/FileUtils.apple.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index f5f017e9..4d5ca1c7 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -21,6 +21,8 @@ import com.infomaniak.auth.lib.internal.extensions.toNsData import com.infomaniak.auth.lib.internal.utils.getApplicationSupportDirectory import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSURLIsExcludedFromBackupKey actual suspend fun checkFileExists(name: String): Boolean { return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") @@ -28,9 +30,16 @@ actual suspend fun checkFileExists(name: String): Boolean { @OptIn(ExperimentalForeignApi::class) actual suspend fun createFile(name: String, content: String) { + val path = "${getApplicationSupportDirectory()}/$name" NSFileManager.defaultManager.createFileAtPath( - path = "${getApplicationSupportDirectory()}/$name", + path = path, contents = content.toNsData(), attributes = null ) + val url = NSURL.fileURLWithPath(path) + url.setResourceValue( + value = true, + forKey = NSURLIsExcludedFromBackupKey, + error = null + ) } From bf32d7246ca20cafd4ed76446ee2c7df23a6512a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 15:47:46 +0200 Subject: [PATCH 13/30] chore: Move getApplicationSupportDirectory to FileUtils.apple.kt This avoids having 2 files with the exact same name (FileUtils.kt), and centralizes this code. --- .../kotlin/internal/utils/FileUtils.kt | 35 ------------------- .../appleMain/kotlin/utils/FileUtils.apple.kt | 15 +++++++- 2 files changed, 14 insertions(+), 36 deletions(-) delete mode 100644 multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt deleted file mode 100644 index 7d485e29..00000000 --- a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Infomaniak Authenticator - 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.auth.lib.internal.utils - -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSApplicationSupportDirectory -import platform.Foundation.NSFileManager -import platform.Foundation.NSUserDomainMask - -@OptIn(ExperimentalForeignApi::class) -internal fun getApplicationSupportDirectory(): String { - val directory = NSFileManager.defaultManager.URLForDirectory( - directory = NSApplicationSupportDirectory, - inDomain = NSUserDomainMask, - appropriateForURL = null, - create = true, - error = null, - ) - return requireNotNull(directory?.path) -} diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index 4d5ca1c7..e4c26df6 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -18,11 +18,12 @@ package com.infomaniak.auth.lib.utils import com.infomaniak.auth.lib.internal.extensions.toNsData -import com.infomaniak.auth.lib.internal.utils.getApplicationSupportDirectory import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSURL import platform.Foundation.NSURLIsExcludedFromBackupKey +import platform.Foundation.NSUserDomainMask actual suspend fun checkFileExists(name: String): Boolean { return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") @@ -43,3 +44,15 @@ actual suspend fun createFile(name: String, content: String) { error = null ) } + +@OptIn(ExperimentalForeignApi::class) +private fun getApplicationSupportDirectory(): String { + val directory = NSFileManager.defaultManager.URLForDirectory( + directory = NSApplicationSupportDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) + return requireNotNull(directory?.path) +} From fab136e622dc43a5544ff0a18aa7393660be1ca0 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 15:59:16 +0200 Subject: [PATCH 14/30] fix: Handle NSError in FileUtils.apple.kt --- .../kotlin/internal/extensions/CFErrors.kt | 41 +++++++++++++++++++ .../appleMain/kotlin/utils/FileUtils.apple.kt | 32 +++++++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt index d9e5b1b5..47bfc9b6 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/extensions/CFErrors.kt @@ -20,8 +20,10 @@ package com.infomaniak.auth.lib.internal.extensions import com.infomaniak.auth.lib.internal.utils.Xor +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr @@ -45,6 +47,8 @@ import platform.Foundation.NSError * } * } * ``` + * + * See [tryIt2] for the full NSError variant (no C-style CoreFoundation). */ internal inline fun tryIt(block: (errorPointer: CPointer) -> R?): Xor = memScoped { val errorVar = alloc() @@ -54,3 +58,40 @@ internal inline fun tryIt(block: (errorPointer: CPointer Xor.Second(error) } } + +/** + * Helpful for functions that take a pointer of an error ref. + * + * Example usage: + * ``` + * val result = tryIt2 { errorPointer -> + * NSFileManager.defaultManager.URLForDirectory( + * directory = NSApplicationSupportDirectory, + * inDomain = NSUserDomainMask, + * appropriateForURL = null, + * create = true, + * error = errorPointer, + * ) + * } + * + * when (result) { + * is Xor.First -> return result.value // Successful + * is Xor.Second -> { + * println("Error: ${result.value.localizedDescription}") + * handleNSError(result.value) + * return null + * } + * } + * ``` + * + * See [tryIt] For the C-style CoreFoundation compatible variant. + */ +@OptIn(BetaInteropApi::class) +internal inline fun tryIt2(block: (errorPtr: CPointer>) -> R?): Xor = memScoped { + val errorVar = alloc>() + val result = block(errorVar.ptr) + when (val error = errorVar.value) { + null -> Xor.First(result!!) + else -> Xor.Second(error) + } +} diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt index e4c26df6..17067da1 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt @@ -17,7 +17,9 @@ */ package com.infomaniak.auth.lib.utils +import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.extensions.toNsData +import com.infomaniak.auth.lib.internal.extensions.tryIt2 import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSFileManager @@ -38,21 +40,25 @@ actual suspend fun createFile(name: String, content: String) { attributes = null ) val url = NSURL.fileURLWithPath(path) - url.setResourceValue( - value = true, - forKey = NSURLIsExcludedFromBackupKey, - error = null - ) + val _ = tryIt2 { + url.setResourceValue( + value = true, + forKey = NSURLIsExcludedFromBackupKey, + error = it + ) + }.firstOrElse { error(it) } } @OptIn(ExperimentalForeignApi::class) private fun getApplicationSupportDirectory(): String { - val directory = NSFileManager.defaultManager.URLForDirectory( - directory = NSApplicationSupportDirectory, - inDomain = NSUserDomainMask, - appropriateForURL = null, - create = true, - error = null, - ) - return requireNotNull(directory?.path) + val directory = tryIt2 { + NSFileManager.defaultManager.URLForDirectory( + directory = NSApplicationSupportDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = it, + ) + }.firstOrElse { error(it) } + return requireNotNull(directory.path) // No reason for it to be null given the code above. } From 802ed845f123952afc30c114088f789c3e4c1f38 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 16:11:50 +0200 Subject: [PATCH 15/30] chore: Put arguments on separate lines with names --- .../kotlin/internal/KeyPairManagerImpl.android.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index fa087b54..f61d97bf 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -64,9 +64,10 @@ internal actual class KeyPairManagerImpl : KeyPairManager { predicate(it.name) } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) - val keyId = - userPassKey.name.substring(userPassKey.name.indexOfFirst { it == '-' } + 1, - userPassKey.name.indexOfLast { it == '-' }) + val keyId = userPassKey.name.substring( + startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, + endIndex = userPassKey.name.indexOfLast { it == '-' } + ) return Xor.First(keyId) } From c19d738e46cdf2d12cca4ddfb2d305f52016774f Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 16:41:35 +0200 Subject: [PATCH 16/30] chore: Rename parameter name for clarity --- .../kotlin/internal/managers/AuthenticatorManager.kt | 4 ++-- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d919bec2..72f6b2bb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -72,9 +72,9 @@ internal class AuthenticatorManager( suspend fun getToken( clientId: String, userId: Long, - keyIdFromOldPasskey: String? = null, + keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdFromOldPasskey ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 8167a464..2de78777 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -65,7 +65,7 @@ internal class MigrationManager( val token = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = keyId, + keyIdOrDefault = keyId, ).firstOrNull()!! // Register a new passkey val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) @@ -73,7 +73,7 @@ internal class MigrationManager( val tokenWithNewPassKey = authenticatorManager.getToken( clientId = clientId, userId = account.id, - keyIdFromOldPasskey = newKeyId, + keyIdOrDefault = newKeyId, ).firstOrNull()!! persistToken(account.id, tokenWithNewPassKey.accessToken) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) From 70291e36408037bcb29d089d7757f9b9105f70b3 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:11:39 +0200 Subject: [PATCH 17/30] chore: Add optional userId to KeyNotFound --- multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt | 2 +- .../kotlin/internal/managers/AuthenticatorManager.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt index f151106e..e0cd52e3 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt @@ -21,6 +21,6 @@ internal sealed interface Failure { sealed interface KeyManagement : Failure { data class GenerationFailed(val details: String) : KeyManagement data class KeyExtractionFailed(val details: String) : KeyManagement - data class KeyNotFound(val details: String) : KeyManagement + data class KeyNotFound(val details: String, val userId: Long? = null) : KeyManagement } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 72f6b2bb..d5cc1d6b 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -74,8 +74,9 @@ internal class AuthenticatorManager( userId: Long, keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() - ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrElse { + return Xor.Second(it.copy(userId = userId)) + } val authenticationOptions = webAuthnRepository.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() From e92e68b1301eeb9060b8d2261b4574fea1ab5da6 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:16:08 +0200 Subject: [PATCH 18/30] chore: Remove unused returned value warning --- .../src/commonMain/kotlin/internal/managers/MigrationManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 2de78777..5ee54891 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -142,7 +142,7 @@ internal class MigrationManager( } authenticatorManager.deleteKeysFor(userId) - authenticatorManager.registerPasskey( + val _ = authenticatorManager.registerPasskey( token = temporaryToken.accessToken, userId = userId ) From f3e417169132f1c94e1d774d395a7be3fca26482 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:21:28 +0200 Subject: [PATCH 19/30] chore: Replace NPEs with ISEs with error messages --- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 5ee54891..5cf25586 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -66,7 +66,7 @@ internal class MigrationManager( clientId = clientId, userId = account.id, keyIdOrDefault = keyId, - ).firstOrNull()!! + ).firstOrElse { error(it) } // Register a new passkey val newKeyId = authenticatorManager.registerPasskey(token.accessToken, account.id) // Getting a new token with the new passkey @@ -74,7 +74,7 @@ internal class MigrationManager( clientId = clientId, userId = account.id, keyIdOrDefault = newKeyId, - ).firstOrNull()!! + ).firstOrElse { error(it) } persistToken(account.id, tokenWithNewPassKey.accessToken) dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore From 3d8183135d3a5b2fec111a250a88eac7bfc72a45 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 17:35:56 +0200 Subject: [PATCH 20/30] chore: Replace unnecessary Xor wrapping with nullable String --- .../internal/KeyPairManagerImpl.android.kt | 6 ++-- .../internal/KeyPairManagerImpl.apple.kt | 30 +++++++++---------- .../commonMain/kotlin/internal/KeyManager.kt | 2 +- .../kotlin/internal/KeyPairManagerImpl.kt | 2 +- .../internal/managers/AuthenticatorManager.kt | 9 +++--- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt index f61d97bf..9641472b 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/KeyPairManagerImpl.android.kt @@ -57,18 +57,18 @@ internal actual class KeyPairManagerImpl : KeyPairManager { }.getOrElse { Xor.Second(Failure.KeyManagement.KeyExtractionFailed(it.toString())) } } - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor { + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? { val userPassKey: File = withContext(Dispatchers.IO) { appCtx.filesDir.listFiles() }?.find { predicate(it.name) - } ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No keys")) + } ?: return null val keyId = userPassKey.name.substring( startIndex = userPassKey.name.indexOfFirst { it == '-' } + 1, endIndex = userPassKey.name.indexOfLast { it == '-' } ) - return Xor.First(keyId) + return keyId } actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor { diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index 4a775e7e..e79f802d 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,27 +126,27 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor = - memScoped { - //TODO[ik-auth]: Test this code somehow. - val (resultsArray, count) = getAllPrivateKeysQuery() + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = memScoped { + //TODO[ik-auth]: Test this code somehow. + val (resultsArray, count) = getAllPrivateKeysQuery() - if (resultsArray == null || count == 0) { - return@memScoped Xor.Second(Failure.KeyManagement.KeyNotFound("No keys found in Keychain")) - } + if (resultsArray == null || count == 0) return@memScoped null - for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + for (i in 0 until count) { + val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) - if (tag != null && predicate(tag)) { - val keyId = tag.substring(tag.indexOfFirst { it == '-' } + 1, tag.indexOfLast { it == '-' }) - return@memScoped Xor.First(keyId) - } + if (tag != null && predicate(tag)) { + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + return@memScoped keyId } - - Xor.Second(Failure.KeyManagement.KeyNotFound("No key found matching $predicate")) } + return@memScoped null + } + actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor = memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt index b6eefbae..9c5f95cb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyManager.kt @@ -31,7 +31,7 @@ internal interface KeyPairManager { suspend fun retrievePrivateKey(userId: Long, keyId: String): Xor - suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor + suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt index b95d1834..8dae8a0c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/KeyPairManagerImpl.kt @@ -27,6 +27,6 @@ internal expect class KeyPairManagerImpl() : KeyPairManager { keyId: String ): Xor - override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): Xor + override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d5cc1d6b..aef5e36c 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -74,9 +74,8 @@ internal class AuthenticatorManager( userId: Long, keyIdOrDefault: String? = null, ): Xor { - val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrElse { - return Xor.Second(it.copy(userId = userId)) - } + val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor { it.startsWith("$userId-") } + ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) val authenticationOptions = webAuthnRepository.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() @@ -125,7 +124,7 @@ internal class AuthenticatorManager( } suspend fun removeAccount(token: String, userId: Long) { - val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") }.firstOrNull() + val passkeyId = keyPairManager.findKeyIdFor { it.startsWith("$userId-") } if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it @@ -141,6 +140,6 @@ internal class AuthenticatorManager( } suspend fun getKeyIdFor(userId: Long): String? { - return keyPairManager.findKeyIdFor({ it.startsWith("$userId-") }).firstOrNull() + return keyPairManager.findKeyIdFor { it.startsWith("$userId-") } } } From 68e48b937de002a16ae7cf7e8165392202aa72a9 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 18:09:14 +0200 Subject: [PATCH 21/30] chore: Make FileUtils contents internal --- .../kotlin/{ => internal}/utils/FileUtils.android.kt | 6 +++--- .../kotlin/{ => internal}/utils/FileUtils.apple.kt | 6 +++--- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 4 ++-- .../src/commonMain/kotlin/{ => internal}/utils/FileUtils.kt | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) rename multiplatform-lib/src/androidMain/kotlin/{ => internal}/utils/FileUtils.android.kt (83%) rename multiplatform-lib/src/appleMain/kotlin/{ => internal}/utils/FileUtils.apple.kt (92%) rename multiplatform-lib/src/commonMain/kotlin/{ => internal}/utils/FileUtils.kt (80%) diff --git a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt similarity index 83% rename from multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt rename to multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt index 8fa4ed53..5b982aeb 100644 --- a/multiplatform-lib/src/androidMain/kotlin/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt @@ -15,18 +15,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.lib.utils +package com.infomaniak.auth.lib.internal.utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.invoke import splitties.init.appCtx import java.io.File -actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { +internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers.IO { File(appCtx.filesDir, name).exists() } -actual suspend fun createFile(name: String, content: String) { +internal actual suspend fun createFile(name: String, content: String) { File(appCtx.filesDir, name).apply { createNewFile() writeText(content) diff --git a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt similarity index 92% rename from multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt rename to multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt index 17067da1..b44c4b30 100644 --- a/multiplatform-lib/src/appleMain/kotlin/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.lib.utils +package com.infomaniak.auth.lib.internal.utils import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.extensions.toNsData @@ -27,12 +27,12 @@ import platform.Foundation.NSURL import platform.Foundation.NSURLIsExcludedFromBackupKey import platform.Foundation.NSUserDomainMask -actual suspend fun checkFileExists(name: String): Boolean { +internal actual suspend fun checkFileExists(name: String): Boolean { return NSFileManager.defaultManager.fileExistsAtPath("${getApplicationSupportDirectory()}/$name") } @OptIn(ExperimentalForeignApi::class) -actual suspend fun createFile(name: String, content: String) { +internal actual suspend fun createFile(name: String, content: String) { val path = "${getApplicationSupportDirectory()}/$name" NSFileManager.defaultManager.createFileAtPath( path = path, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 5cf25586..7cc77010 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -34,8 +34,8 @@ import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException -import com.infomaniak.auth.lib.utils.checkFileExists -import com.infomaniak.auth.lib.utils.createFile +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException diff --git a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt similarity index 80% rename from multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt rename to multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 52bde963..bb5cfc14 100644 --- a/multiplatform-lib/src/commonMain/kotlin/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -15,8 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.auth.lib.utils +package com.infomaniak.auth.lib.internal.utils -expect suspend fun createFile(name: String, content: String) +internal expect suspend fun createFile(name: String, content: String) -expect suspend fun checkFileExists(name: String): Boolean +internal expect suspend fun checkFileExists(name: String): Boolean From 65c4261e035ce2c634573e0a5cd08ed689968588 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:16:29 +0200 Subject: [PATCH 22/30] chore: Rename createFile to createBackupExcludedFile --- .../androidMain/kotlin/internal/utils/FileUtils.android.kt | 6 +++++- .../src/appleMain/kotlin/internal/utils/FileUtils.apple.kt | 2 +- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 6 +++--- .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 6 +++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt index 5b982aeb..2267b783 100644 --- a/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt +++ b/multiplatform-lib/src/androidMain/kotlin/internal/utils/FileUtils.android.kt @@ -26,7 +26,11 @@ internal actual suspend fun checkFileExists(name: String): Boolean = Dispatchers File(appCtx.filesDir, name).exists() } -internal actual suspend fun createFile(name: String, content: String) { +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { File(appCtx.filesDir, name).apply { createNewFile() writeText(content) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt index b44c4b30..6e986016 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/utils/FileUtils.apple.kt @@ -32,7 +32,7 @@ internal actual suspend fun checkFileExists(name: String): Boolean { } @OptIn(ExperimentalForeignApi::class) -internal actual suspend fun createFile(name: String, content: String) { +internal actual suspend fun createBackupExcludedFile(name: String, content: String) { val path = "${getApplicationSupportDirectory()}/$name" NSFileManager.defaultManager.createFileAtPath( path = path, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 7cc77010..0ff3163a 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -32,10 +32,10 @@ import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException -import com.infomaniak.auth.lib.internal.utils.checkFileExists -import com.infomaniak.auth.lib.internal.utils.createFile import com.osmerion.kotlin.io.encoding.Base32 import io.ktor.utils.io.core.toByteArray import kotlinx.io.IOException @@ -190,7 +190,7 @@ internal class MigrationManager( } suspend fun createAccountInitializationFile() { - createFile( + createBackupExcludedFile( name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), ) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index bb5cfc14..12081bcb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -17,6 +17,10 @@ */ package com.infomaniak.auth.lib.internal.utils -internal expect suspend fun createFile(name: String, content: String) +/** + * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, + * or implement a BackupAgent to have the backup exclusion work. + */ +internal expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean From f05611d682c096d053f6c518eb66940b5ade4a54 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:17:39 +0200 Subject: [PATCH 23/30] refactor: Introduce RestoreFromBackupDetector --- .../internal/RestoreFromBackupDetector.kt | 48 +++++++++++++++++++ .../internal/managers/MigrationManager.kt | 22 +-------- 2 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt new file mode 100644 index 00000000..ac1159ac --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -0,0 +1,48 @@ +/* + * Infomaniak Authenticator - 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.auth.lib.internal + +import com.infomaniak.auth.lib.internal.utils.checkFileExists +import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile +import kotlin.random.Random + +internal object RestoreFromBackupDetector { + + private val restorationHandledMarkerFileName: String = "51756f69203f".hexToByteArray().decodeToString() + + suspend inline fun runRestoreOperationIfNeeded(block: () -> Unit) { + val restorationAlreadyHandled = doesRestoreHandledFileExist() + if (restorationAlreadyHandled) return + block() + markRestorationAsHandled() + } + + private suspend fun doesRestoreHandledFileExist(): Boolean { + return checkFileExists(restorationHandledMarkerFileName) + } + + private suspend fun markRestorationAsHandled() { + createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) + } + + private fun generateFileContent(): String { + val oldEnough = Random.nextBoolean() + val encodedContent = if (oldEnough) "466575722021" else "51756f69636f75626568" + return encodedContent.hexToByteArray().decodeToString() + } +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index 0ff3163a..f54138bf 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -18,6 +18,7 @@ package com.infomaniak.auth.lib.internal.managers import com.infomaniak.auth.lib.internal.MigrationAuthentication +import com.infomaniak.auth.lib.internal.RestoreFromBackupDetector import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable @@ -32,8 +33,6 @@ import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository -import com.infomaniak.auth.lib.internal.utils.checkFileExists -import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import com.infomaniak.auth.lib.models.migration.ApiToken import com.infomaniak.auth.lib.network.exceptions.ApiException import com.osmerion.kotlin.io.encoding.Base32 @@ -53,9 +52,8 @@ internal class MigrationManager( private val dao = accountsDatabase.getDao() suspend fun setBackedUpAccountsStatus() { - if (!doesAccountInitializationFileExist()) { + RestoreFromBackupDetector.runRestoreOperationIfNeeded { dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) - createAccountInitializationFile() } } @@ -180,20 +178,4 @@ internal class MigrationManager( scope = this.scope, ) } - - companion object { - private const val ACCOUNT_INITIALIZATION_FILE_NAME = "51756f69203f" - private const val ACCOUNT_INITIALIZATION_FILE_CONTENT = "466575722021" - - suspend fun doesAccountInitializationFileExist(): Boolean { - return checkFileExists(name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString()) - } - - suspend fun createAccountInitializationFile() { - createBackupExcludedFile( - name = ACCOUNT_INITIALIZATION_FILE_NAME.hexToByteArray().decodeToString(), - content = ACCOUNT_INITIALIZATION_FILE_CONTENT.hexToByteArray().decodeToString(), - ) - } - } } From e4beccc1527954bebcd56580462cae55fa5b6eb6 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:17:58 +0200 Subject: [PATCH 24/30] chore: Put arguments on separate lines --- .../commonMain/kotlin/internal/managers/MigrationManager.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index f54138bf..9a030b4e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -53,7 +53,10 @@ internal class MigrationManager( suspend fun setBackedUpAccountsStatus() { RestoreFromBackupDetector.runRestoreOperationIfNeeded { - dao.updateStatus(currentStatus = AccountEntity.Status.LoggedIn, newStatus = AccountEntity.Status.RestoringFromBackup) + dao.updateStatus( + currentStatus = AccountEntity.Status.LoggedIn, + newStatus = AccountEntity.Status.RestoringFromBackup + ) } } From 4441b2c75c0750ad12482e7fcd36a12c2bfddb61 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 19:18:53 +0200 Subject: [PATCH 25/30] fix: Handle exceptions and retries for restoration from backup Otherwise, the app will crash after restoring if there's a network or other kind of communication issue. --- .../kotlin/internal/AuthenticatorFacadeImpl.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5c13b0c1..57575101 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -220,9 +220,7 @@ internal class AuthenticatorFacadeImpl( registrationAttempts(entity) } AccountEntity.Status.RestoringFromBackup -> { - migrationManager.restore(account = entity) { userId, token -> - authenticatorBridge.persistTokenForAccount(userId, token) - } + restoreFromBackupAttempts(account = entity) } AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. } @@ -365,6 +363,14 @@ internal class AuthenticatorFacadeImpl( } } + private suspend fun FlowCollector.restoreFromBackupAttempts(account: AccountEntity) { + withRetries(userId = account.id) { + migrationManager.restore(account = account) { userId, token -> + authenticatorBridge.persistTokenForAccount(userId, token) + } + } + } + private suspend inline fun FlowCollector.withRetries( userId: Long, From 8277189eab6adbe104dd362f3785869056b6689a Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Mon, 27 Apr 2026 18:09:47 +0200 Subject: [PATCH 26/30] perf: Use Dispatchers.IO for keychain operations --- .../internal/KeyPairManagerImpl.apple.kt | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt index e79f802d..d5159398 100644 --- a/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt +++ b/multiplatform-lib/src/appleMain/kotlin/internal/KeyPairManagerImpl.apple.kt @@ -126,28 +126,32 @@ internal actual class KeyPairManagerImpl : KeyPairManager { } @OptIn(BetaInteropApi::class) - actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = memScoped { - //TODO[ik-auth]: Test this code somehow. - val (resultsArray, count) = getAllPrivateKeysQuery() - - if (resultsArray == null || count == 0) return@memScoped null + actual override suspend fun findKeyIdFor(predicate: (name: String) -> Boolean): String? = Dispatchers.IO { + memScoped { + //TODO[ik-auth]: Test this code somehow. + val (resultsArray, count) = getAllPrivateKeysQuery() - for (i in 0 until count) { - val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + if (resultsArray == null || count == 0) return@memScoped null - if (tag != null && predicate(tag)) { - val keyId = tag.substring( - startIndex = tag.indexOfFirst { it == '-' } + 1, - endIndex = tag.indexOfLast { it == '-' } - ) - return@memScoped keyId + for (i in 0 until count) { + val tag = extractTagFromItem(CFArrayGetValueAtIndex(resultsArray, i.toLong())) + + if (tag != null && predicate(tag)) { + val keyId = tag.substring( + startIndex = tag.indexOfFirst { it == '-' } + 1, + endIndex = tag.indexOfLast { it == '-' } + ) + return@memScoped keyId + } } - } - return@memScoped null + return@memScoped null + } } - actual override suspend fun deleteKeysMatching(predicate: (name: String) -> Boolean): Xor = + actual override suspend fun deleteKeysMatching( + predicate: (name: String) -> Boolean + ): Xor = Dispatchers.IO { memScoped { val (resultsArray, count) = getAllPrivateKeysQuery() @@ -171,6 +175,7 @@ internal actual class KeyPairManagerImpl : KeyPairManager { Xor.Second(Failure.KeyManagement.KeyNotFound("No key containing $predicate")) } } + } @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) private fun MemScope.getAllPrivateKeysQuery(): Pair { From 3018388a7cf91b30ffc7ceffb9504762672fe657 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 09:12:35 +0200 Subject: [PATCH 27/30] chore: Add BackupExclusionGotcha opt-in requirement annotation --- .../commonMain/kotlin/internal/RestoreFromBackupDetector.kt | 2 ++ .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt index ac1159ac..225c57ee 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.auth.lib.internal +import com.infomaniak.auth.lib.internal.utils.BackupExclusionGotcha import com.infomaniak.auth.lib.internal.utils.checkFileExists import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import kotlin.random.Random @@ -37,6 +38,7 @@ internal object RestoreFromBackupDetector { } private suspend fun markRestorationAsHandled() { + @OptIn(BackupExclusionGotcha::class) createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 12081bcb..3f47f0f5 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -17,10 +17,15 @@ */ package com.infomaniak.auth.lib.internal.utils +@RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + + "Backup rules or logic in a BackupAgent are required on Android.") +annotation class BackupExclusionGotcha + /** * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, * or implement a BackupAgent to have the backup exclusion work. */ +@BackupExclusionGotcha internal expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean From d7c497420d7776aa475c7560cd4c7111e5956840 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Tue, 28 Apr 2026 09:23:47 +0200 Subject: [PATCH 28/30] chore: Rename BackupExclusionGotcha to BackupExclusionOnlyApplePlatforms --- .../commonMain/kotlin/internal/RestoreFromBackupDetector.kt | 4 ++-- .../src/commonMain/kotlin/internal/utils/FileUtils.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt index 225c57ee..7b5d0817 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -17,7 +17,7 @@ */ package com.infomaniak.auth.lib.internal -import com.infomaniak.auth.lib.internal.utils.BackupExclusionGotcha +import com.infomaniak.auth.lib.internal.utils.BackupExclusionOnlyApplePlatforms import com.infomaniak.auth.lib.internal.utils.checkFileExists import com.infomaniak.auth.lib.internal.utils.createBackupExcludedFile import kotlin.random.Random @@ -38,7 +38,7 @@ internal object RestoreFromBackupDetector { } private suspend fun markRestorationAsHandled() { - @OptIn(BackupExclusionGotcha::class) + @OptIn(BackupExclusionOnlyApplePlatforms::class) createBackupExcludedFile(name = restorationHandledMarkerFileName, content = generateFileContent()) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt index 3f47f0f5..a63834d0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/FileUtils.kt @@ -19,13 +19,13 @@ package com.infomaniak.auth.lib.internal.utils @RequiresOptIn(message = "Backup exclusion is supported only on Apple platforms. " + "Backup rules or logic in a BackupAgent are required on Android.") -annotation class BackupExclusionGotcha +annotation class BackupExclusionOnlyApplePlatforms /** * **WARNING:** The backup exclusion is Apple/iOS only. On Android, you need to configure the backup rules, * or implement a BackupAgent to have the backup exclusion work. */ -@BackupExclusionGotcha +@BackupExclusionOnlyApplePlatforms internal expect suspend fun createBackupExcludedFile(name: String, content: String) internal expect suspend fun checkFileExists(name: String): Boolean From 887245d157b7f80138cd7f7ab4cb03c427096032 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Tue, 28 Apr 2026 09:32:07 +0200 Subject: [PATCH 29/30] chore: Remove unused userId --- multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt index e0cd52e3..f151106e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/Failure.kt @@ -21,6 +21,6 @@ internal sealed interface Failure { sealed interface KeyManagement : Failure { data class GenerationFailed(val details: String) : KeyManagement data class KeyExtractionFailed(val details: String) : KeyManagement - data class KeyNotFound(val details: String, val userId: Long? = null) : KeyManagement + data class KeyNotFound(val details: String) : KeyManagement } } From 9376168e094973ad184deb848d7acfe94c7824d1 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Tue, 28 Apr 2026 09:33:54 +0200 Subject: [PATCH 30/30] chore: Rename method to avoid an unnecessary val --- .../commonMain/kotlin/internal/RestoreFromBackupDetector.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt index 7b5d0817..b8a3edc6 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/RestoreFromBackupDetector.kt @@ -27,13 +27,12 @@ internal object RestoreFromBackupDetector { private val restorationHandledMarkerFileName: String = "51756f69203f".hexToByteArray().decodeToString() suspend inline fun runRestoreOperationIfNeeded(block: () -> Unit) { - val restorationAlreadyHandled = doesRestoreHandledFileExist() - if (restorationAlreadyHandled) return + if (restorationAlreadyHandled()) return block() markRestorationAsHandled() } - private suspend fun doesRestoreHandledFileExist(): Boolean { + private suspend fun restorationAlreadyHandled(): Boolean { return checkFileExists(restorationHandledMarkerFileName) }