Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Check failure on line 27 in Auth/src/fdroid/kotlin/com/infomaniak/core/auth/backup/BlockStoreBackup.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=Infomaniak_android-core&issues=AaD30ElSLoVygz5kd-8L&open=AaD30ElSLoVygz5kd-8L&pullRequest=870

suspend fun backupTokens(): Boolean = false
suspend fun restoreTokens(): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,7 @@ import kotlinx.coroutines.runBlocking

context(_: FullBackupAgent)
inline fun withBlockStoreCredentialsBackup(
data: FullBackupDataOutput,
Comment thread
LouisCAD marked this conversation as resolved.
Resolved
crossinline backupCredentials: suspend () -> Boolean = { BlockStoreBackup.backupTokens() },
crossinline defaultBackupCalls: () -> Unit
): Unit = runBlocking {
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>? = null): Map<String, ByteArray>
abstract suspend fun deleteBytes(keys: List<String>): 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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -85,23 +85,19 @@ object BlockStoreBackup {
val keysToDelete: List<String> = 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<String> = buildLongObjectMap {
db.userDao().allUsers().forEach { user -> this[user.id.toLong()] = user.apiToken.accessToken }
}

private suspend fun readTokensBackup(): LongObjectMap<String>? {
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)
}
}
}
Expand All @@ -115,6 +111,4 @@ object BlockStoreBackup {
}
}
}

private suspend fun isE2eeAvailable(): Boolean = blockstoreClient.isEndToEndEncryptionAvailable.await()
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>?): Map<String, ByteArray> {
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<String>): 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
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
@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() } }
Comment thread
LouisCAD marked this conversation as resolved.
Resolved
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)
Comment thread
LouisCAD marked this conversation as resolved.
Resolved
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<String>?): Map<String, ByteArray> = 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<String>): 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<String> {
return (dir.list()?.asList() ?: emptyList<String>()).map { it.removeSuffix(shouldBackupToCloudSuffix) }
}
}
Loading