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) } + } +}