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/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 ebd3fcdb3..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
@@ -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
+ 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/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..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,29 +17,30 @@
*/
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.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.backup.FullBackupAgent
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
+ context(agent: FullBackupAgent)
+ fun backupTestBlockStoreIfNeeded(data: FullBackupDataOutput) {
+ BlockStore.instance.backupTestBlockStoreIfNeeded(data)
+ }
+
suspend fun backupTokens(): Boolean {
val backupContent = dumpTokens()
val alreadyBackedUpContent = readTokensBackup()
@@ -61,13 +62,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 +85,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 +93,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 +111,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..1d35546f9
--- /dev/null
+++ b/Auth/src/standard/kotlin/com/infomaniak/core/auth/backup/BlockStoreTestingImpl.kt
@@ -0,0 +1,84 @@
+/*
+ * 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 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()
+ }
+ }
+
+ 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
+ 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) }
+ }
+}