Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+58
View File
@@ -0,0 +1,58 @@
apply plugin: 'kotlin-parcelize'
apply from: '../scripts/secrets.gradle'
android {
namespace 'io.novafoundation.nova.feature_cloud_backup_impl'
defaultConfig {
buildConfigField "String", "GOOGLE_OAUTH_ID", readStringSecret("DEBUG_GOOGLE_OAUTH_ID")
}
buildTypes {
release {
buildConfigField "String", "GOOGLE_OAUTH_ID", readStringSecret("RELEASE_GOOGLE_OAUTH_ID")
}
}
packagingOptions {
resources.excludes.add("META-INF/DEPENDENCIES")
}
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation coroutinesDep
implementation project(':runtime')
implementation project(":common")
api project(":feature-cloud-backup-api")
implementation androidDep
implementation daggerDep
ksp daggerCompiler
implementation androidDep
api project(':core-api')
api project(':core-db')
implementation playServicesAuthDep
implementation googleApiClientDep
implementation googleDriveDep
testImplementation project(':test-shared')
testImplementation project(":feature-cloud-backup-test")
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
@JvmInline
value class UnencryptedPrivateData(val unencryptedData: String)
@JvmInline
value class EncryptedPrivateData(val encryptedData: ByteArray)
class SerializedBackup<PRIVATE>(
val publicData: CloudBackup.PublicData,
val privateData: PRIVATE
)
@JvmInline
value class ReadyForStorageBackup(val value: String)
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage
import io.novafoundation.nova.common.utils.InformationSize
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError
import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup
internal interface CloudBackupStorage {
suspend fun hasEnoughFreeStorage(neededSize: InformationSize): Result<Boolean>
suspend fun isCloudStorageServiceAvailable(): Boolean
suspend fun isUserAuthenticated(): Boolean
suspend fun authenticateUser(): Result<Unit>
suspend fun checkBackupExists(): Result<Boolean>
suspend fun writeBackup(backup: ReadyForStorageBackup): Result<Unit>
/**
* @throws FetchBackupError.BackupNotFound
*/
suspend fun fetchBackup(): Result<ReadyForStorageBackup>
suspend fun deleteBackup(): Result<Unit>
}
@@ -0,0 +1,283 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage
import android.app.Activity.RESULT_OK
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.auth.UserRecoverableAuthException
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.Scope
import com.google.android.gms.tasks.Task
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.HttpResponseException
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.google.api.services.drive.model.File
import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.resources.requireActivity
import io.novafoundation.nova.common.utils.InformationSize
import io.novafoundation.nova.common.utils.InformationSize.Companion.bytes
import io.novafoundation.nova.common.utils.mapErrorNotInstance
import io.novafoundation.nova.common.utils.systemCall.SystemCall
import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError
import io.novafoundation.nova.feature_cloud_backup_impl.BuildConfig
import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
internal class GoogleDriveBackupStorage(
private val contextManager: ContextManager,
private val systemCallExecutor: SystemCallExecutor,
private val oauthClientId: String,
private val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider,
private val debug: Boolean = BuildConfig.DEBUG
) : CloudBackupStorage {
companion object {
private const val BACKUP_MIME_TYPE = "application/json"
}
private val drive: Drive by lazy {
createGoogleDriveService()
}
override suspend fun hasEnoughFreeStorage(neededSize: InformationSize): Result<Boolean> = withContext(Dispatchers.IO) {
runCatchingRecoveringAuthErrors {
val remainingSpaceInDrive = getRemainingSpace()
remainingSpaceInDrive >= neededSize
}
}
override suspend fun isCloudStorageServiceAvailable(): Boolean {
return googleApiAvailabilityProvider.isAvailable()
}
override suspend fun isUserAuthenticated(): Boolean = withContext(Dispatchers.IO) {
val account = GoogleSignIn.getLastSignedInAccount(contextManager.getApplicationContext())
account != null
}
override suspend fun authenticateUser(): Result<Unit> = withContext(Dispatchers.IO) {
val systemCall = GoogleSignInSystemCall(contextManager, oauthClientId, driveScope())
systemCallExecutor.executeSystemCall(systemCall)
}
override suspend fun checkBackupExists(): Result<Boolean> = withContext(Dispatchers.IO) {
runCatchingRecoveringAuthErrors {
checkBackupExistsUnsafe()
}
}
override suspend fun writeBackup(backup: ReadyForStorageBackup): Result<Unit> = withContext(Dispatchers.IO) {
runCatchingRecoveringAuthErrors {
writeBackupFileToDrive(backup.value)
}
}
override suspend fun fetchBackup(): Result<ReadyForStorageBackup> = withContext(Dispatchers.IO) {
runCatchingRecoveringAuthErrors {
val fileContent = readBackupFileFromDrive()
ReadyForStorageBackup(fileContent)
}.mapErrorNotInstance<_, FetchBackupError> {
when (it) {
is UserRecoverableAuthException,
is UserRecoverableAuthIOException -> FetchBackupError.AuthFailed
else -> it
}
}
}
override suspend fun deleteBackup(): Result<Unit> = withContext(Dispatchers.IO) {
runCatchingRecoveringAuthErrors {
deleteBackupFileFromDrive()
}
}
private suspend fun <T> runCatchingRecoveringAuthErrors(action: suspend () -> T): Result<T> {
return runCatching { action() }
.recoverCatching {
when (it) {
is UserRecoverableAuthException -> it.askForConsent()
is UserRecoverableAuthIOException -> it.cause?.askForConsent()
else -> throw it
}
action()
}
}
private fun writeBackupFileToDrive(fileContent: String) {
val contentStream = ByteArrayContent(BACKUP_MIME_TYPE, fileContent.encodeToByteArray())
val backupInCloud = getBackupFileFromCloud()
if (backupInCloud != null) {
drive.files()
.update(backupInCloud.id, null, contentStream)
.execute()
} else {
val fileMetadata = File().apply { name = backupFileName() }
drive.files().create(fileMetadata, contentStream)
.execute()
}
}
private fun readBackupFileFromDrive(): String {
val outputStream = ByteArrayOutputStream()
val backupFile = getBackupFileFromCloud() ?: throw FetchBackupError.BackupNotFound
try {
drive.files()
.get(backupFile.id)
.executeMediaAndDownloadTo(outputStream)
} catch (e: HttpResponseException) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416
// Not handle 416 error, to handle it as corrupted backup
if (e.statusCode != 416) {
throw e
}
}
return outputStream.toString()
}
private fun deleteBackupFileFromDrive() {
val backupFile = getBackupFileFromCloud() ?: return
drive.files()
.delete(backupFile.id)
.execute()
}
private fun checkBackupExistsUnsafe(): Boolean {
return getBackupFileFromCloud() != null
}
private fun getBackupFileFromCloud(): File? {
return drive.files().list()
.setQ(backupNameQuery())
.setSpaces("drive")
.setFields("files(id, name)")
.execute()
.files
.firstOrNull()
}
private fun getRemainingSpace(): InformationSize {
val about = drive.about().get().setFields("storageQuota").execute()
val totalSpace: Long = about.storageQuota.limit
val usedSpace: Long = about.storageQuota.usage
val remainingSpace = totalSpace - usedSpace
return remainingSpace.bytes
}
private suspend fun UserRecoverableAuthException.askForConsent() {
systemCallExecutor.executeSystemCall(RemoteConsentSystemCall(this))
}
private fun backupNameQuery(): String {
return "name = '" + backupFileName().replace("'", "\\'") + "' and trashed = false"
}
private fun createGoogleDriveService(): Drive {
val context = contextManager.getApplicationContext()
val account = GoogleSignIn.getLastSignedInAccount(context)
val credential = GoogleAccountCredential.usingOAuth2(context, listOf(driveScope()))
credential.selectedAccount = account!!.account
return Drive.Builder(NetHttpTransport(), GsonFactory(), credential)
.setApplicationName("Pezkuwi Wallet")
.build()
}
private fun driveScope(): String = DriveScopes.DRIVE_FILE
private fun backupFileName(): String {
return if (debug) {
"pezkuwiwallet_backup_debug.json"
} else {
"pezkuwiwallet_backup.json"
}
}
}
private class GoogleSignInSystemCall(
private val contextManager: ContextManager,
private val oauthClientId: String,
private val scope: String
) : SystemCall<Unit> {
companion object {
private const val REQUEST_CODE = 9001
}
override fun createRequest(activity: AppCompatActivity): SystemCall.Request {
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestIdToken(oauthClientId)
.requestScopes(Scope(scope))
.build()
val googleSignInClient = GoogleSignIn.getClient(contextManager.requireActivity(), signInOptions)
val signInIntent = googleSignInClient.signInIntent
return SystemCall.Request(
intent = signInIntent,
requestCode = REQUEST_CODE
)
}
override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result<Unit> {
val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(intent)
return try {
task.getResult(ApiException::class.java)
Result.success(Unit)
} catch (e: ApiException) {
Result.failure(e)
}
}
}
private class RemoteConsentSystemCall(
private val consentException: UserRecoverableAuthException,
) : SystemCall<Unit> {
companion object {
private const val REQUEST_CODE = 9002
}
override fun createRequest(activity: AppCompatActivity): SystemCall.Request {
val intent = consentException.intent!!
return SystemCall.Request(intent, REQUEST_CODE)
}
override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result<Unit> {
return if (resultCode == RESULT_OK) {
Result.success(Unit)
} else {
Result.failure(consentException)
}
}
}
@@ -0,0 +1,80 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data.encryption
import io.novafoundation.nova.common.utils.dropBytes
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError
import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData
import io.novafoundation.nova.feature_cloud_backup_impl.data.UnencryptedPrivateData
import io.novasama.substrate_sdk_android.encrypt.json.copyBytes
import io.novasama.substrate_sdk_android.encrypt.xsalsa20poly1305.SecretBox
import org.bouncycastle.crypto.generators.SCrypt
import java.security.SecureRandom
import java.util.Random
interface CloudBackupEncryption {
suspend fun encryptBackup(data: UnencryptedPrivateData, password: String): Result<EncryptedPrivateData>
/**
* @throws InvalidBackupPasswordError
*/
suspend fun decryptBackup(data: EncryptedPrivateData, password: String): Result<UnencryptedPrivateData>
}
class ScryptCloudBackupEncryption : CloudBackupEncryption {
private val random: Random = SecureRandom()
companion object {
private const val SCRYPT_KEY_SIZE = 32
private const val SALT_SIZE = 32
private const val NONCE_SIZE = 24
private const val N = 16384
private const val p = 1
private const val r = 8
}
override suspend fun encryptBackup(data: UnencryptedPrivateData, password: String): Result<EncryptedPrivateData> {
return runCatching {
val salt = generateSalt()
val encryptionKey = generateScryptKey(password.encodeToByteArray(), salt)
val plaintext = data.unencryptedData.encodeToByteArray()
val secretBox = SecretBox(encryptionKey)
val nonce = secretBox.nonce(plaintext)
val secret = secretBox.seal(nonce, plaintext)
val encryptedData = salt + nonce + secret
EncryptedPrivateData(encryptedData)
}
}
override suspend fun decryptBackup(data: EncryptedPrivateData, password: String): Result<UnencryptedPrivateData> {
return runCatching {
val salt = data.encryptedData.copyBytes(from = 0, size = SALT_SIZE)
val nonce = data.encryptedData.copyBytes(from = SALT_SIZE, size = NONCE_SIZE)
val encryptedContent = data.encryptedData.dropBytes(SALT_SIZE + NONCE_SIZE)
val encryptionSecret = generateScryptKey(password.encodeToByteArray(), salt)
val secret = SecretBox(encryptionSecret).open(nonce, encryptedContent)
if (secret.isEmpty()) {
throw InvalidBackupPasswordError()
}
UnencryptedPrivateData(secret.decodeToString())
}
}
private fun generateScryptKey(password: ByteArray, salt: ByteArray): ByteArray {
return SCrypt.generate(password, salt, N, r, p, SCRYPT_KEY_SIZE)
}
private fun generateSalt(): ByteArray {
return ByteArray(SALT_SIZE).also {
random.nextBytes(it)
}
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data.preferences
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Date
interface CloudBackupPreferences {
suspend fun syncWithCloudEnabled(): Boolean
suspend fun setSyncWithCloudEnabled(enabled: Boolean)
fun observeLastSyncedTime(): Flow<Date?>
suspend fun setLastSyncedTime(date: Date)
suspend fun setSavedPassword(password: String)
suspend fun getSavedPassword(): String?
fun getCloudBackupWasInitialized(): Boolean
fun setCloudBackupWasInitialized(value: Boolean)
}
suspend fun CloudBackupPreferences.enableSyncWithCloud() = setSyncWithCloudEnabled(true)
internal class SharedPrefsCloudBackupPreferences(
private val preferences: Preferences,
private val encryptedPreferences: EncryptedPreferences
) : CloudBackupPreferences {
companion object {
private const val CLOUD_BACKUP_WAS_INITIALIZED = "CloudBackupPreferences.cloud_backup_was_initialized"
private const val SYNC_WITH_CLOUD_ENABLED_KEY = "CloudBackupPreferences.sync_with_cloud_enabled"
private const val LAST_SYNC_TIME_KEY = "CloudBackupPreferences.last_sync_time"
private const val BACKUP_ENABLED_DEFAULT = false
private const val PASSWORD_KEY = "CloudBackupPreferences.backup_password"
}
override suspend fun syncWithCloudEnabled(): Boolean {
return preferences.getBoolean(SYNC_WITH_CLOUD_ENABLED_KEY, BACKUP_ENABLED_DEFAULT)
}
override suspend fun setSyncWithCloudEnabled(enabled: Boolean) {
preferences.putBoolean(SYNC_WITH_CLOUD_ENABLED_KEY, enabled)
}
override fun observeLastSyncedTime(): Flow<Date?> {
return preferences.keyFlow(LAST_SYNC_TIME_KEY).map {
if (preferences.contains(it)) {
Date(preferences.getLong(it, 0))
} else {
null
}
}
}
override suspend fun setLastSyncedTime(date: Date) {
preferences.putLong(LAST_SYNC_TIME_KEY, date.time)
}
override suspend fun setSavedPassword(password: String) {
encryptedPreferences.putEncryptedString(PASSWORD_KEY, password)
}
override suspend fun getSavedPassword(): String? {
return encryptedPreferences.getDecryptedString(PASSWORD_KEY)
}
override fun getCloudBackupWasInitialized(): Boolean {
return preferences.getBoolean(CLOUD_BACKUP_WAS_INITIALIZED, false)
}
override fun setCloudBackupWasInitialized(value: Boolean) {
preferences.putBoolean(CLOUD_BACKUP_WAS_INITIALIZED, value)
}
}
@@ -0,0 +1,85 @@
package io.novafoundation.nova.feature_cloud_backup_impl.data.serializer
import com.google.gson.GsonBuilder
import io.novafoundation.nova.common.utils.ByteArrayHexAdapter
import io.novafoundation.nova.common.utils.InformationSize
import io.novafoundation.nova.common.utils.InformationSize.Companion.megabytes
import io.novafoundation.nova.common.utils.fromJson
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData
import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup
import io.novafoundation.nova.feature_cloud_backup_impl.data.SerializedBackup
import io.novafoundation.nova.feature_cloud_backup_impl.data.UnencryptedPrivateData
interface CloudBackupSerializer {
suspend fun neededSizeForBackup(): InformationSize
suspend fun serializePrivateData(backup: CloudBackup): Result<SerializedBackup<UnencryptedPrivateData>>
suspend fun serializePublicData(backup: SerializedBackup<EncryptedPrivateData>): Result<ReadyForStorageBackup>
suspend fun deserializePublicData(backup: ReadyForStorageBackup): Result<SerializedBackup<EncryptedPrivateData>>
suspend fun deserializePrivateData(backup: SerializedBackup<UnencryptedPrivateData>): Result<CloudBackup>
}
internal class JsonCloudBackupSerializer() : CloudBackupSerializer {
private val gson = GsonBuilder()
.registerTypeHierarchyAdapter(ByteArray::class.java, ByteArrayHexAdapter())
.create()
companion object {
private val neededSizeForBackup: InformationSize = 10.megabytes
}
override suspend fun neededSizeForBackup(): InformationSize {
return neededSizeForBackup
}
override suspend fun serializePrivateData(backup: CloudBackup): Result<SerializedBackup<UnencryptedPrivateData>> {
return runCatching {
val privateDataSerialized = gson.toJson(backup.privateData)
SerializedBackup(
publicData = backup.publicData,
privateData = UnencryptedPrivateData(privateDataSerialized)
)
}
}
override suspend fun serializePublicData(backup: SerializedBackup<EncryptedPrivateData>): Result<ReadyForStorageBackup> {
return runCatching {
ReadyForStorageBackup(gson.toJson(backup))
}
}
override suspend fun deserializePublicData(backup: ReadyForStorageBackup): Result<SerializedBackup<EncryptedPrivateData>> {
return runCatching {
gson.fromJson<SerializedBackup<EncryptedPrivateData>>(backup.value).also {
// Gson doesn't fail on missing fields so we do some preliminary checks here
requireNotNull(it.publicData)
requireNotNull(it.privateData)
// Do not allow empty backups
require(it.publicData.wallets.isNotEmpty())
}
}
}
override suspend fun deserializePrivateData(backup: SerializedBackup<UnencryptedPrivateData>): Result<CloudBackup> {
return runCatching {
val privateData: CloudBackup.PrivateData = gson.fromJson<CloudBackup.PrivateData>(backup.privateData.unencryptedData).also {
// Gson doesn't fail on missing fields so we do some preliminary checks here
requireNotNull(it.wallets)
}
CloudBackup(
publicData = backup.publicData,
privateData = privateData
)
}
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_cloud_backup_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
CloudBackupFeatureDependencies::class
],
modules = [
CloudBackupFeatureModule::class,
]
)
@FeatureScope
interface CloudBackupFeatureComponent : CloudBackupFeatureApi {
@Component.Factory
interface Factory {
fun create(
@BindsInstance accountRouter: CloudBackupRouter,
deps: CloudBackupFeatureDependencies
): CloudBackupFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
RuntimeApi::class
]
)
interface CloudBackupFeatureDependenciesComponent : CloudBackupFeatureDependencies
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_cloud_backup_impl.di
import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
interface CloudBackupFeatureDependencies {
val contextManager: ContextManager
val systemCallExecutor: SystemCallExecutor
val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider
val preferences: Preferences
val encryptedPreferences: EncryptedPreferences
val resourceManager: ResourceManager
val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_cloud_backup_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class CloudBackupFeatureHolder @Inject constructor(
private val cloudBackupRouter: CloudBackupRouter,
featureContainer: FeatureContainer,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dependencies = DaggerCloudBackupFeatureComponent_CloudBackupFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.build()
return DaggerCloudBackupFeatureComponent.factory()
.create(cloudBackupRouter, dependencies)
}
}
@@ -0,0 +1,94 @@
package io.novafoundation.nova.feature_cloud_backup_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import io.novafoundation.nova.feature_cloud_backup_impl.BuildConfig
import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.CloudBackupStorage
import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.GoogleDriveBackupStorage
import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.CloudBackupEncryption
import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.ScryptCloudBackupEncryption
import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.CloudBackupPreferences
import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.SharedPrefsCloudBackupPreferences
import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.CloudBackupSerializer
import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.JsonCloudBackupSerializer
import io.novafoundation.nova.feature_cloud_backup_impl.domain.RealCloudBackupService
import io.novafoundation.nova.feature_cloud_backup_impl.presentation.mixin.RealCloudBackupChangingWarningMixinFactory
@Module
internal class CloudBackupFeatureModule {
@Provides
@FeatureScope
fun provideCloudStorage(
contextManager: ContextManager,
systemCallExecutor: SystemCallExecutor,
googleApiAvailabilityProvider: GoogleApiAvailabilityProvider,
): CloudBackupStorage {
return GoogleDriveBackupStorage(
contextManager = contextManager,
systemCallExecutor = systemCallExecutor,
oauthClientId = BuildConfig.GOOGLE_OAUTH_ID,
googleApiAvailabilityProvider = googleApiAvailabilityProvider
)
}
@Provides
@FeatureScope
fun provideBackupSerializer(): CloudBackupSerializer {
return JsonCloudBackupSerializer()
}
@Provides
@FeatureScope
fun provideBackupEncryption(): CloudBackupEncryption {
return ScryptCloudBackupEncryption()
}
@Provides
@FeatureScope
fun provideBackupPreferences(preferences: Preferences, encryptedPreferences: EncryptedPreferences): CloudBackupPreferences {
return SharedPrefsCloudBackupPreferences(preferences, encryptedPreferences)
}
@Provides
@FeatureScope
fun provideCloudBackupService(
cloudBackupStorage: CloudBackupStorage,
backupSerializer: CloudBackupSerializer,
encryption: CloudBackupEncryption,
backupPreferences: CloudBackupPreferences,
): CloudBackupService {
return RealCloudBackupService(
storage = cloudBackupStorage,
serializer = backupSerializer,
encryption = encryption,
cloudBackupPreferences = backupPreferences
)
}
@Provides
@FeatureScope
fun provideCloudBackupChangingWarningMixinFactory(
preferences: Preferences,
resourceManager: ResourceManager,
cloudBackupService: CloudBackupService,
actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
): CloudBackupChangingWarningMixinFactory {
return RealCloudBackupChangingWarningMixinFactory(
preferences,
resourceManager,
cloudBackupService,
actionBottomSheetLauncherFactory
)
}
}
@@ -0,0 +1,188 @@
package io.novafoundation.nova.feature_cloud_backup_impl.domain
import android.util.Log
import io.novafoundation.nova.common.utils.flatMap
import io.novafoundation.nova.common.utils.mapError
import io.novafoundation.nova.common.utils.mapErrorNotInstance
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupSession
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.EncryptedCloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.DeleteBackupError
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.WriteBackupError
import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData
import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup
import io.novafoundation.nova.feature_cloud_backup_impl.data.SerializedBackup
import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.CloudBackupStorage
import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.CloudBackupEncryption
import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.CloudBackupPreferences
import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.enableSyncWithCloud
import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.CloudBackupSerializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.util.Date
internal class RealCloudBackupService(
private val storage: CloudBackupStorage,
private val serializer: CloudBackupSerializer,
private val encryption: CloudBackupEncryption,
private val cloudBackupPreferences: CloudBackupPreferences,
) : CloudBackupService {
override val session: CloudBackupSession = RealCloudBackupSession()
override suspend fun validateCanCreateBackup(): PreCreateValidationStatus = withContext(Dispatchers.IO) {
validateCanCreateBackupInternal()
}
override suspend fun writeBackupToCloud(request: WriteBackupRequest): Result<Unit> = withContext(Dispatchers.IO) {
storage.ensureUserAuthenticated().flatMap {
cloudBackupPreferences.enableSyncWithCloud()
prepareBackupForSaving(request.cloudBackup, request.password)
}
.flatMap {
storage.writeBackup(it)
}.onFailure {
Log.e("CloudBackupService", "Failed to write backup to cloud", it)
}.mapErrorNotInstance<_, WriteBackupError> {
WriteBackupError.Other
}
}
override suspend fun isCloudBackupExist(): Result<Boolean> = withContext(Dispatchers.IO) {
storage.ensureUserAuthenticated()
.flatMap {
storage.checkBackupExists()
}
}
override suspend fun fetchBackup(): Result<EncryptedCloudBackup> {
return withContext(Dispatchers.IO) {
storage.ensureUserAuthenticated()
.mapErrorNotInstance<_, FetchBackupError> { FetchBackupError.AuthFailed }
.flatMap { storage.fetchBackup() }
.flatMap {
serializer.deserializePublicData(it)
.mapError { FetchBackupError.CorruptedBackup }
}
.map { RealEncryptedCloudBackup(encryption, serializer, it) }
.onFailure {
Log.e("CloudBackupService", "Failed to read backup from the cloud", it)
}.mapErrorNotInstance<_, FetchBackupError> { FetchBackupError.Other }
}
}
override suspend fun deleteBackup(): Result<Unit> {
return storage.ensureUserAuthenticated().flatMap {
storage.deleteBackup()
}.onFailure {
Log.e("CloudBackupService", "Failed to delete backup from the cloud", it)
}.mapErrorNotInstance<_, DeleteBackupError> {
DeleteBackupError.Other
}
}
override suspend fun signInToCloud(): Result<Unit> {
return storage.authenticateUser()
}
private suspend fun prepareBackupForSaving(backup: CloudBackup, password: String): Result<ReadyForStorageBackup> {
return serializer.serializePrivateData(backup)
.flatMap { unencryptedBackupData -> encryption.encryptBackup(unencryptedBackupData.privateData, password) }
.map { SerializedBackup(backup.publicData, it) }
.flatMap { serializer.serializePublicData(it) }
}
private suspend fun CloudBackupStorage.ensureUserAuthenticated(): Result<Unit> {
return if (!isUserAuthenticated()) {
authenticateUser()
} else {
Result.success(Unit)
}
}
private suspend fun validateCanCreateBackupInternal(): PreCreateValidationStatus {
if (!storage.isCloudStorageServiceAvailable()) return PreCreateValidationStatus.BackupServiceUnavailable
storage.ensureUserAuthenticated().getOrNull() ?: return PreCreateValidationStatus.AuthenticationFailed
val fileExists = storage.checkBackupExists().getOrNull() ?: return PreCreateValidationStatus.OtherError
if (fileExists) {
return PreCreateValidationStatus.ExistingBackupFound
}
val hasEnoughSize = hasEnoughSizeForBackup().getOrNull() ?: return PreCreateValidationStatus.OtherError
if (!hasEnoughSize) {
return PreCreateValidationStatus.NotEnoughSpace
}
return PreCreateValidationStatus.Ok
}
private suspend fun hasEnoughSizeForBackup(): Result<Boolean> {
val neededBackupSize = serializer.neededSizeForBackup()
return storage.hasEnoughFreeStorage(neededBackupSize)
}
private class RealEncryptedCloudBackup(
private val encryption: CloudBackupEncryption,
private val serializer: CloudBackupSerializer,
private val encryptedBackup: SerializedBackup<EncryptedPrivateData>
) : EncryptedCloudBackup {
override val publicData: CloudBackup.PublicData = encryptedBackup.publicData
override suspend fun decrypt(password: String): Result<CloudBackup> {
return encryption.decryptBackup(encryptedBackup.privateData, password).flatMap { privateData ->
val unencryptedBackup = SerializedBackup(encryptedBackup.publicData, privateData)
serializer.deserializePrivateData(unencryptedBackup)
.mapError { FetchBackupError.CorruptedBackup }
}
}
}
private inner class RealCloudBackupSession : CloudBackupSession {
override suspend fun isSyncWithCloudEnabled(): Boolean {
return cloudBackupPreferences.syncWithCloudEnabled()
}
override suspend fun setSyncingBackupEnabled(enable: Boolean) {
cloudBackupPreferences.setSyncWithCloudEnabled(enable)
}
override fun lastSyncedTimeFlow(): Flow<Date?> {
return cloudBackupPreferences.observeLastSyncedTime()
}
override suspend fun setLastSyncedTime(date: Date) {
cloudBackupPreferences.setLastSyncedTime(date)
}
override suspend fun getSavedPassword(): Result<String> {
return runCatching {
cloudBackupPreferences.getSavedPassword() ?: throw PasswordNotSaved()
}
}
override suspend fun setSavedPassword(password: String) {
cloudBackupPreferences.setSavedPassword(password)
}
override fun cloudBackupWasInitialized(): Boolean {
return cloudBackupPreferences.getCloudBackupWasInitialized()
}
override fun setBackupWasInitialized() {
cloudBackupPreferences.setCloudBackupWasInitialized(true)
}
}
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_cloud_backup_impl.presentation
import io.novafoundation.nova.common.navigation.ReturnableRouter
interface CloudBackupRouter : ReturnableRouter
@@ -0,0 +1,126 @@
package io.novafoundation.nova.feature_cloud_backup_impl.presentation.mixin
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.CheckBoxPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.negative
import io.novafoundation.nova.common.view.bottomSheet.action.primary
import io.novafoundation.nova.common.view.bottomSheet.action.secondary
import io.novafoundation.nova.feature_cloud_backup_api.R
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixin
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
private const val KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN = "cloud_backup_change_warning_shown"
class RealCloudBackupChangingWarningMixinFactory(
private val preferences: Preferences,
private val resourceManager: ResourceManager,
private val cloudBackupService: CloudBackupService,
private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
) : CloudBackupChangingWarningMixinFactory {
override fun create(coroutineScope: CoroutineScope): CloudBackupChangingWarningMixin {
return RealCloudBackupChangingWarningMixin(
coroutineScope,
preferences,
resourceManager,
cloudBackupService,
actionBottomSheetLauncherFactory
)
}
}
class RealCloudBackupChangingWarningMixin(
private val scope: CoroutineScope,
private val preferences: Preferences,
private val resourceManager: ResourceManager,
private val cloudBackupService: CloudBackupService,
actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
) : CloudBackupChangingWarningMixin {
override val actionBottomSheetLauncher: ActionBottomSheetLauncher = actionBottomSheetLauncherFactory.create()
override fun launchChangingConfirmationIfNeeded(onConfirm: () -> Unit) {
scope.launch {
// In case if cloud backup sync is disabled, we don't need to show the warning and can confirm the action now
if (!cloudBackupService.session.isSyncWithCloudEnabled()) {
onConfirm()
return@launch
}
if (preferences.getBoolean(KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN, false)) {
onConfirm()
return@launch
}
actionBottomSheetLauncher.launchCloudBackupChangingWarning(resourceManager, onConfirm)
}
}
override fun launchRemovingConfirmationIfNeeded(onConfirm: () -> Unit) {
scope.launch {
// In case if cloud backup sync is disabled, we don't need to show the warning and can confirm the action now
if (!cloudBackupService.session.isSyncWithCloudEnabled()) {
onConfirm()
return@launch
}
actionBottomSheetLauncher.launchCloudBackupRemovingWarning(resourceManager, onConfirm)
}
}
private fun ActionBottomSheetLauncher.launchCloudBackupChangingWarning(
resourceManager: ResourceManager,
onConfirm: () -> Unit
) {
var isAutoContinueChecked = false
launchBottomSheet(
imageRes = R.drawable.ic_cloud_backup_add,
title = resourceManager.getString(R.string.cloud_backup_will_be_changed_title),
subtitle = resourceManager.getString(R.string.cloud_backup_will_be_changed_subtitle),
neutralButtonPreferences = ButtonPreferences.secondary(
resourceManager.getString(
R.string.common_cancel
)
),
actionButtonPreferences = ButtonPreferences.primary(
resourceManager.getString(R.string.common_continue),
onClick = {
preferences.putBoolean(KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN, isAutoContinueChecked)
onConfirm()
}
),
checkBoxPreferences = CheckBoxPreferences(
text = resourceManager.getString(R.string.common_check_box_auto_continue),
onCheckChanged = { isChecked -> isAutoContinueChecked = isChecked }
)
)
}
private fun ActionBottomSheetLauncher.launchCloudBackupRemovingWarning(
resourceManager: ResourceManager,
onConfirm: () -> Unit
) {
launchBottomSheet(
imageRes = R.drawable.ic_cloud_backup_delete,
title = resourceManager.getString(R.string.cloud_backup_removing_warning_title),
subtitle = resourceManager.getString(R.string.cloud_backup_removing_warning_subtitle),
neutralButtonPreferences = ButtonPreferences.secondary(
resourceManager.getString(
R.string.common_cancel
)
),
actionButtonPreferences = ButtonPreferences.negative(
resourceManager.getString(R.string.common_remove),
onClick = onConfirm
)
)
}
}
@@ -0,0 +1,358 @@
package io.novafoundation.nova.feature_cloud_backup_api.domain.model
import io.novafoundation.feature_cloud_backup_test.CloudBackupBuilder
import io.novafoundation.feature_cloud_backup_test.buildTestCloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isEmpty
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.localVsCloudDiff
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import org.junit.Test
class CloudBackupExtKtTest {
@Test
fun shouldDiffWithEmptyLocalBackup() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("1") {
}
}
}
val walletOnlyPresentInCloud = cloudBackup.publicData.wallets.first()
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.modified.isEmpty())
assertEquals(listOf(walletOnlyPresentInCloud), diff.localChanges.added)
}
@Test
fun shouldFindAddLocalAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32))
}
wallet("1") {
substrateAccountId(ByteArray(32))
}
}
}
val walletOnlyPresentInCloud = cloudBackup.publicData.wallets[1]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.modified.isEmpty())
assertEquals(listOf(walletOnlyPresentInCloud), diff.localChanges.added)
}
@Test
fun shouldFindRemoveLocalAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
wallet("1") {
substrateAccountId(ByteArray(32))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val walletOnlyPresentLocally = localBackup.publicData.wallets[1]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.added.isEmpty())
assertTrue(diff.localChanges.modified.isEmpty())
assertEquals(listOf(walletOnlyPresentLocally), diff.localChanges.removed)
}
@Test
fun shouldFindModifiedLocalAccountName() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
name("old")
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32))
name("new")
}
}
}
val modifiedLocally = cloudBackup.publicData.wallets[0]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.added.isEmpty())
assertEquals(listOf(modifiedLocally), diff.localChanges.modified)
}
@Test
fun shouldFindModifiedLocalBaseSubstrateAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32) { 1 })
}
}
}
val modifiedLocally = cloudBackup.publicData.wallets[0]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.added.isEmpty())
assertEquals(listOf(modifiedLocally), diff.localChanges.modified)
}
@Test
fun shouldFindModifiedLocalBaseEthereumAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
ethereumAddress(ByteArray(20))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
ethereumAddress(ByteArray(20) { 1 })
}
}
}
val toModifyLocally = cloudBackup.publicData.wallets[0]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.added.isEmpty())
assertEquals(listOf(toModifyLocally), diff.localChanges.modified)
}
@Test
fun shouldFindModifiedLocalChainAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
chainAccount(chainId = "0") {
accountId(ByteArray(32))
}
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
chainAccount(chainId = "0") {
accountId(ByteArray(32) { 1 })
}
}
}
}
val toModifyLocally = cloudBackup.publicData.wallets[0]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.cloudChanges.isEmpty())
assertTrue(diff.localChanges.removed.isEmpty())
assertTrue(diff.localChanges.added.isEmpty())
assertEquals(listOf(toModifyLocally), diff.localChanges.modified)
}
@Test
fun shouldFindAddCloudAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32))
}
wallet("1") {
substrateAccountId(ByteArray(32))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val walletOnlyPresentLocally = localBackup.publicData.wallets[1]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.localChanges.isEmpty())
assertTrue(diff.cloudChanges.removed.isEmpty())
assertTrue(diff.cloudChanges.modified.isEmpty())
assertEquals(listOf(walletOnlyPresentLocally), diff.cloudChanges.added)
}
@Test
fun shouldFindRemoveCloudAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
wallet("1") {
substrateAccountId(ByteArray(32))
}
}
}
val walletOnlyPresentInCloud = cloudBackup.publicData.wallets[1]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.localChanges.isEmpty())
assertTrue(diff.cloudChanges.added.isEmpty())
assertTrue(diff.cloudChanges.modified.isEmpty())
assertEquals(listOf(walletOnlyPresentInCloud), diff.cloudChanges.removed)
}
@Test
fun shouldFindModifiedCloudAccount() {
val localBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(1)
wallet("0") {
substrateAccountId(ByteArray(32) { 1 })
}
}
}
val cloudBackup = buildTestCloudBackupWithoutPrivate {
publicData {
modifiedAt(0)
wallet("0") {
substrateAccountId(ByteArray(32))
}
}
}
val toModifyInCloud = localBackup.publicData.wallets[0]
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud())
assertTrue(diff.localChanges.isEmpty())
assertTrue(diff.cloudChanges.removed.isEmpty())
assertTrue(diff.cloudChanges.added.isEmpty())
assertEquals(listOf(toModifyInCloud), diff.cloudChanges.modified)
}
private inline fun buildTestCloudBackupWithoutPrivate(crossinline builder: CloudBackupBuilder.() -> Unit) = buildTestCloudBackup {
builder()
privateData {
}
}
}