mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-22 07:57:59 +00:00
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:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
+17
@@ -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)
|
||||
+27
@@ -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>
|
||||
}
|
||||
+283
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
+94
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+188
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package io.novafoundation.nova.feature_cloud_backup_impl.presentation
|
||||
|
||||
import io.novafoundation.nova.common.navigation.ReturnableRouter
|
||||
|
||||
interface CloudBackupRouter : ReturnableRouter
|
||||
+126
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+358
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user