From d4b04995337b20cf26b6cbee323fec7a42f87b35 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 1 Oct 2026 11:46:19 +0200 Subject: [PATCH 1/4] feat: Add testing Block Store Because Block Store doesn't restore data on Google APIs emulators once the app has been uninstalled or had its data cleared. This should allow testing in debug without going through the tedious process of: 1. Preparing the app for a release 2. Publishing the release 3. Installing it on the target test device 4. Setting up the app (horrible passwords to type) 5. Triggering the backup 6. Waiting for it to complete (minutes) 7. Resetting the device 8. Waiting for it to complete (minutes) 9. Setting up the device (horrible passwords to type) 10. Waiting for each step to complete (minutes) 11. Waiting for the apps to get reinstalled by the Play Store 12. Hoping you don't have to restart at step 1 because it didn't work and the app is not debuggable --- Auth/build.gradle.kts | 1 + .../backup/BlockStoreCredentialsBackup.kt | 3 + .../infomaniak/core/auth/backup/BlockStore.kt | 42 ++++++++++ .../core/auth/backup/BlockStoreBackup.kt | 33 +++----- .../auth/backup/BlockStorePlayServicesImpl.kt | 62 ++++++++++++++ .../core/auth/backup/BlockStoreTestingImpl.kt | 80 +++++++++++++++++++ 6 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStore.kt create mode 100644 Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStorePlayServicesImpl.kt create mode 100644 Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 41acf0129..673155124 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(core.kotlinx.serialization.json) implementation(core.gson) implementation(core.splitties.appctx) + implementation(core.splitties.bitflags) implementation(core.okhttp) api(core.gson) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt index ebd3fcdb3..6b0999c10 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.core.auth.backup +import android.app.backup.FullBackupDataOutput import androidx.room.execSQL import androidx.room.immediateTransaction import androidx.room.useWriterConnection @@ -27,6 +28,7 @@ import kotlinx.coroutines.runBlocking context(_: FullBackupAgent) inline fun withBlockStoreCredentialsBackup( + data: FullBackupDataOutput, crossinline backupCredentials: suspend () -> Boolean = { BlockStoreBackup.backupTokens() }, crossinline defaultBackupCalls: () -> Unit ): Unit = runBlocking { @@ -36,6 +38,7 @@ inline fun withBlockStoreCredentialsBackup( } val succeeded = backupCredentials() if (!succeeded) return@runBlocking // Abort backup + BlockStore.instance.backupTestBlockStoreIfNeeded(data) val db = UserDatabase.instance // We don't want to keep tokens in the db for backup, so we remove them temporarily. // Note that the app can perfectly recover from this state if the backup process is aborted, here's why: diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStore.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStore.kt new file mode 100644 index 000000000..3436a436f --- /dev/null +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStore.kt @@ -0,0 +1,42 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.auth.backup + +import android.app.backup.FullBackupDataOutput +import android.content.pm.ApplicationInfo +import com.infomaniak.core.common.backup.FullBackupAgent +import splitties.bitflags.hasFlag +import splitties.init.appCtx + +sealed class BlockStore { + + abstract suspend fun storeBytes(key: String, shouldBackupToCloud: Boolean, bytes: ByteArray) + abstract suspend fun retrieveBytes(keys: List? = null): Map + abstract suspend fun deleteBytes(keys: List): Boolean + abstract suspend fun isE2eeAvailable(): Boolean + + context(agent: FullBackupAgent) + abstract fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) + + companion object { + val instance: BlockStore = when { + appCtx.applicationInfo.flags.hasFlag(ApplicationInfo.FLAG_DEBUGGABLE) -> BlockStoreTestingImpl(e2eeAvailable = true) + else -> BlockStorePlayServicesImpl() + } + } +} diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt index 362d8e586..82e479732 100644 --- a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt @@ -21,21 +21,15 @@ import androidx.collection.LongObjectMap import androidx.collection.buildLongObjectMap import androidx.room.immediateTransaction import androidx.room.useWriterConnection -import com.google.android.gms.auth.blockstore.Blockstore -import com.google.android.gms.auth.blockstore.DeleteBytesRequest -import com.google.android.gms.auth.blockstore.RetrieveBytesRequest -import com.google.android.gms.auth.blockstore.StoreBytesData import com.infomaniak.core.auth.room.UserDatabase import com.infomaniak.core.common.cancellable import com.infomaniak.core.sentry.SentryLog -import kotlinx.coroutines.tasks.await -import splitties.init.appCtx object BlockStoreBackup { private const val TAG = "BlockStoreBackup" - private val blockstoreClient = Blockstore.getClient(appCtx) + private val blockStore = BlockStore.instance private val db = UserDatabase.instance const val isSupported: Boolean = true @@ -61,13 +55,12 @@ object BlockStoreBackup { // This can happen if a previous backup was aborted, leaving tokens in the Block Store, but out of the DB. return@count false // Didn't fail. } - val storeRequest = StoreBytesData.Builder() - .setKey(userId.toString()) - .setShouldBackupToCloud(true) - .setBytes(accessToken.toByteArray()) - .build() runCatching { - blockstoreClient.storeBytes(storeRequest).await() + blockStore.storeBytes( + key = userId.toString(), + shouldBackupToCloud = true, + bytes = accessToken.toByteArray() + ) false // Didn't fail. }.cancellable().getOrElse { throwable -> SentryLog.wtf(TAG, "Failed to backup token", throwable) @@ -85,8 +78,7 @@ object BlockStoreBackup { val keysToDelete: List = buildList { previousDump.forEachKey { key -> if (key !in tokensDump) add(key.toString()) } } - val deleteRequest = DeleteBytesRequest.Builder().setKeys(keysToDelete).build() - blockstoreClient.deleteBytes(deleteRequest).await() + blockStore.deleteBytes(keysToDelete) } private suspend fun dumpTokens(): LongObjectMap = buildLongObjectMap { @@ -94,14 +86,11 @@ object BlockStoreBackup { } private suspend fun readTokensBackup(): LongObjectMap? { - val retrieveRequest = RetrieveBytesRequest.Builder() - .setRetrieveAll(true) - .build() - val dataMap = blockstoreClient.retrieveBytes(retrieveRequest).await().blockstoreDataMap.ifEmpty { return null } + val dataMap = blockStore.retrieveBytes().ifEmpty { return null } return buildLongObjectMap { - dataMap.forEach { (key, data) -> + dataMap.forEach { (key, bytes) -> val userId = key.toLongOrNull() ?: return@forEach - this[userId] = String(data.bytes) + this[userId] = String(bytes) } } } @@ -115,6 +104,4 @@ object BlockStoreBackup { } } } - - private suspend fun isE2eeAvailable(): Boolean = blockstoreClient.isEndToEndEncryptionAvailable.await() } diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStorePlayServicesImpl.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStorePlayServicesImpl.kt new file mode 100644 index 000000000..aa41c8365 --- /dev/null +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStorePlayServicesImpl.kt @@ -0,0 +1,62 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.auth.backup + +import android.app.backup.FullBackupDataOutput +import com.google.android.gms.auth.blockstore.Blockstore +import com.google.android.gms.auth.blockstore.DeleteBytesRequest +import com.google.android.gms.auth.blockstore.RetrieveBytesRequest +import com.google.android.gms.auth.blockstore.StoreBytesData +import com.infomaniak.core.common.backup.FullBackupAgent +import kotlinx.coroutines.tasks.await +import splitties.init.appCtx + +internal class BlockStorePlayServicesImpl : BlockStore() { + private val client = Blockstore.getClient(appCtx) + + override suspend fun storeBytes( + key: String, + shouldBackupToCloud: Boolean, + bytes: ByteArray + ) { + val storeRequest = StoreBytesData.Builder() + .setKey(key) + .setShouldBackupToCloud(shouldBackupToCloud) + .setBytes(bytes) + .build() + client.storeBytes(storeRequest).await() + } + + override suspend fun retrieveBytes(keys: List?): Map { + val retrieveRequest = RetrieveBytesRequest.Builder() + .setKeys(keys ?: emptyList()) + .setRetrieveAll(keys == null) + .build() + return client.retrieveBytes(retrieveRequest).await().blockstoreDataMap.mapValues { it.value.bytes } + } + + override suspend fun deleteBytes(keys: List): Boolean { + val deleteRequest = DeleteBytesRequest.Builder().setKeys(keys).build() + return client.deleteBytes(deleteRequest).await() + } + + override suspend fun isE2eeAvailable(): Boolean = client.isEndToEndEncryptionAvailable.await() + + context(agent: FullBackupAgent) + override fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) = Unit +} diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt new file mode 100644 index 000000000..b5ca24be4 --- /dev/null +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt @@ -0,0 +1,80 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalAtomicApi::class) + +package com.infomaniak.core.auth.backup + +import android.app.backup.FullBackupDataOutput +import com.infomaniak.core.common.backup.FullBackupAgent +import com.infomaniak.core.common.backup.isDeviceToDeviceTransfer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.invoke +import splitties.init.appCtx +import java.io.File +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +internal class BlockStoreTestingImpl(private val e2eeAvailable: Boolean) : BlockStore() { + + private val dir by lazy { appCtx.filesDir.resolve("debugBlockStore").also { it.mkdir() } } + private val shouldBackupToCloudSuffix = "-cloud-ok" + + override suspend fun storeBytes(key: String, shouldBackupToCloud: Boolean, bytes: ByteArray) { + Dispatchers.IO { + val file = dir.resolve(if (shouldBackupToCloud) key + shouldBackupToCloudSuffix else key) + file.writeBytes(bytes) + } + } + + override suspend fun retrieveBytes(keys: List?): Map = Dispatchers.IO { + val targetKeys = keys ?: getStoredKeys() + buildMap(capacity = targetKeys.size) { + targetKeys.forEach { key -> + val bytes = fileForKey(key)?.readBytes() ?: return@forEach + this[key] = bytes + } + } + } + + override suspend fun deleteBytes(keys: List): Boolean = Dispatchers.IO { + var deleted = false + keys.forEach { key -> + deleted = dir.resolve(key).delete() + deleted = dir.resolve(key + shouldBackupToCloudSuffix).delete() || deleted + } + deleted + } + + override suspend fun isE2eeAvailable(): Boolean = e2eeAvailable + + context(agent: FullBackupAgent) + override fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) { + dir.listFiles()?.forEach { file -> + if (data.isDeviceToDeviceTransfer || file.name.endsWith(shouldBackupToCloudSuffix)) { + agent.fullBackupFile(file, data) + } + } + } + + private fun fileForKey(key: String): File? { + return dir.resolve(key).takeIf { it.exists() } ?: dir.resolve(key + shouldBackupToCloudSuffix).takeIf { it.exists() } + } + + private fun getStoredKeys(): List { + return (dir.list()?.asList() ?: emptyList()).map { it.removeSuffix(shouldBackupToCloudSuffix) } + } +} From 25accfb0f4f089d158c818312159a3d0befb2e86 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 1 Oct 2026 15:58:35 +0200 Subject: [PATCH 2/4] fix: Fix F-Droid build --- .../com/infomaniak/core/auth/backup/BlockStoreBackup.kt | 7 +++++++ .../core/auth/backup/BlockStoreCredentialsBackup.kt | 2 +- .../com/infomaniak/core/auth/backup/BlockStoreBackup.kt | 7 +++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Auth/src/fdroid/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt b/Auth/src/fdroid/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt index c6b2d5753..08335d839 100644 --- a/Auth/src/fdroid/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt +++ b/Auth/src/fdroid/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt @@ -17,8 +17,15 @@ */ package com.infomaniak.core.auth.backup +import android.app.backup.FullBackupDataOutput +import com.infomaniak.core.common.backup.FullBackupAgent + object BlockStoreBackup { const val isSupported: Boolean = false + + context(agent: FullBackupAgent) + fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) {} + suspend fun backupTokens(): Boolean = false suspend fun restoreTokens(): Boolean = false } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt index 6b0999c10..f82a7445a 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/backup/BlockStoreCredentialsBackup.kt @@ -38,7 +38,7 @@ inline fun withBlockStoreCredentialsBackup( } val succeeded = backupCredentials() if (!succeeded) return@runBlocking // Abort backup - BlockStore.instance.backupTestBlockStoreIfNeeded(data) + BlockStoreBackup.backupTestBlockStoreIfNeeded(data) val db = UserDatabase.instance // We don't want to keep tokens in the db for backup, so we remove them temporarily. // Note that the app can perfectly recover from this state if the backup process is aborted, here's why: diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt index 82e479732..73039a129 100644 --- a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt @@ -17,11 +17,13 @@ */ package com.infomaniak.core.auth.backup +import android.app.backup.FullBackupDataOutput import androidx.collection.LongObjectMap import androidx.collection.buildLongObjectMap import androidx.room.immediateTransaction import androidx.room.useWriterConnection import com.infomaniak.core.auth.room.UserDatabase +import com.infomaniak.core.common.backup.FullBackupAgent import com.infomaniak.core.common.cancellable import com.infomaniak.core.sentry.SentryLog @@ -34,6 +36,11 @@ object BlockStoreBackup { const val isSupported: Boolean = true + context(agent: FullBackupAgent) + fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) { + BlockStore.instance.backupTestBlockStoreIfNeeded(data) + } + suspend fun backupTokens(): Boolean { val backupContent = dumpTokens() val alreadyBackedUpContent = readTokensBackup() From 0767b7bc90b9a19e1bf930f1ff286ea79423414c Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 1 Oct 2026 16:01:56 +0200 Subject: [PATCH 3/4] fix: Fix deleted result overwritten on loop iteration --- .../com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt index b5ca24be4..3ef51b104 100644 --- a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt @@ -53,7 +53,7 @@ internal class BlockStoreTestingImpl(private val e2eeAvailable: Boolean) : Block override suspend fun deleteBytes(keys: List): Boolean = Dispatchers.IO { var deleted = false keys.forEach { key -> - deleted = dir.resolve(key).delete() + deleted = dir.resolve(key).delete() || deleted deleted = dir.resolve(key + shouldBackupToCloudSuffix).delete() || deleted } deleted From ea46f0849f9cfb3f9432c817d974a716a40af39c Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 1 Oct 2026 16:08:47 +0200 Subject: [PATCH 4/4] fix: Delete previous file if shouldBackupToCloud is changed --- .../infomaniak/core/auth/backup/BlockStoreTestingImpl.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt index 3ef51b104..1d35546f9 100644 --- a/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt +++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt @@ -35,8 +35,12 @@ internal class BlockStoreTestingImpl(private val e2eeAvailable: Boolean) : Block override suspend fun storeBytes(key: String, shouldBackupToCloud: Boolean, bytes: ByteArray) { Dispatchers.IO { - val file = dir.resolve(if (shouldBackupToCloud) key + shouldBackupToCloudSuffix else key) + val cloudOkFile = dir.resolve(key + shouldBackupToCloudSuffix) + val deviceToDeviceOnlyFile = dir.resolve(key) + val file = if (shouldBackupToCloud) cloudOkFile else deviceToDeviceOnlyFile file.writeBytes(bytes) + val fileToDelete = if (shouldBackupToCloud) deviceToDeviceOnlyFile else cloudOkFile + fileToDelete.delete() } }