From 624bee88ef15122ef9d2e89e3340123c43f98849 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 16 Oct 2024 11:12:03 +0200 Subject: [PATCH] Address book remote storage implementation (#1632) * Address book remote storage implementation * Code cleanup * Biometrics enabled * Error handling * Code cleanup * Merging strategy * Offline bugfixes * Code cleanup * Performance update for address book CRUD * Performance update for transaction history * Proguard update * Documentation update * Documentation update --- CHANGELOG.md | 1 + app/proguard-project.txt | 10 + docs/whatsNew/WHATS_NEW_EN.md | 1 + gradle.properties | 8 +- settings.gradle.kts | 13 +- ui-design-lib/build.gradle.kts | 1 - ui-lib/build.gradle.kts | 20 +- .../electriccoin/zcash/di/DateSourceModule.kt | 6 +- .../electriccoin/zcash/di/ProviderModule.kt | 9 +- .../co/electriccoin/zcash/ui/MainActivity.kt | 64 +++++ .../datasource/LocalAddressBookDataSource.kt | 125 ++------ .../datasource/RemoteAddressBookDataSource.kt | 197 +++++++++++++ .../datasource/RemoteAddressBookProvider.kt | 22 -- .../ui/common/provider/AddressBookProvider.kt | 100 +++++++ .../provider/AddressBookStorageProvider.kt | 39 +++ .../LocalAddressBookStorageProvider.kt | 44 --- .../repository/AddressBookRepository.kt | 271 ++++++++++++++++-- .../viewmodel/TransactionHistoryViewModel.kt | 107 ++++--- .../viewmodel/AddressBookViewModel.kt | 2 +- .../contact/viewmodel/AddContactViewModel.kt | 3 + .../viewmodel/UpdateContactViewModel.kt | 2 + 21 files changed, 803 insertions(+), 242 deletions(-) create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookDataSource.kt delete mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookProvider.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt delete mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/LocalAddressBookStorageProvider.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d41d5521..07b8f4cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2 - Address Book, Create/Update/Delete Contact, Create Contact by QR screens added ### Added +- Address book local and remote storage support - New QR Code detail screen has been added ## [1.2 (739)] - 2024-09-27 diff --git a/app/proguard-project.txt b/app/proguard-project.txt index 07c3634d..fdc604de 100644 --- a/app/proguard-project.txt +++ b/app/proguard-project.txt @@ -23,6 +23,16 @@ -dontwarn javax.naming.NamingEnumeration -dontwarn javax.naming.NamingException +-dontwarn javax.naming.InvalidNameException +-dontwarn javax.naming.ldap.LdapName +-dontwarn javax.naming.ldap.Rdn +-dontwarn org.ietf.jgss.GSSContext +-dontwarn org.ietf.jgss.GSSCredential +-dontwarn org.ietf.jgss.GSSException +-dontwarn org.ietf.jgss.GSSManager +-dontwarn org.ietf.jgss.GSSName +-dontwarn org.ietf.jgss.Oid + # kotlinx.datetime supports kotlinx.serialization, but we don't use kotlinx.serialization elsewhere # in the projects, so the classes aren't present. These warnings are safe to suppress. -dontwarn kotlinx.serialization.KSerializer diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index 73122b7b..2796d473 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -18,6 +18,7 @@ directly impact users rather than highlighting other key architectural updates.* - Address Book, Create/Update/Delete Contact, Create Contact by QR screens added ### Added +- Address book local and remote storage support - New QR Code detail screen has been added ## [1.2 (739)] - 2024-09-27 diff --git a/gradle.properties b/gradle.properties index 3dff7f02..219f9f50 100644 --- a/gradle.properties +++ b/gradle.properties @@ -162,7 +162,7 @@ KTLINT_VERSION=1.2.1 KOIN_VERSION=3.5.6 ACCOMPANIST_PERMISSIONS_VERSION=0.34.0 -ANDROIDX_ACTIVITY_VERSION=1.8.2 +ANDROIDX_ACTIVITY_VERSION=1.9.2 ANDROIDX_ANNOTATION_VERSION=1.7.1 ANDROIDX_BIOMETRIC_VERSION=1.2.0-alpha05 ANDROIDX_CAMERA_VERSION=1.3.2 @@ -174,7 +174,7 @@ ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.1 ANDROIDX_CORE_VERSION=1.12.0 ANDROIDX_ESPRESSO_VERSION=3.5.1 ANDROIDX_LIFECYCLE_VERSION=2.7.0 -ANDROIDX_FRAGMENT_VERSION=1.8.1 +ANDROIDX_FRAGMENT_VERSION=1.8.4 ANDROIDX_NAVIGATION_COMPOSE_VERSION=2.7.7 ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.1 ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha06 @@ -206,6 +206,10 @@ PLAY_APP_UPDATE_KTX_VERSION=2.1.0 PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZXING_VERSION=3.5.3 +GOOGLE_HTTP_CLIENT_GSON_VERSION=1.45.0 +GOOGLE_API_CLIENT_ANDROID_VERSION=1.26.0 +GOOGLE_API_SERVICES_DRIVE_VERSION=v3-rev136-1.25.0 +PLAY_SERVICES_AUTH_VERSION=21.2.0 ZCASH_BIP39_VERSION=1.0.8 diff --git a/settings.gradle.kts b/settings.gradle.kts index 4ad4bd43..7a919d41 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -185,12 +185,22 @@ dependencyResolutionManagement { val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString() val zxingVersion = extra["ZXING_VERSION"].toString() val koinVersion = extra["KOIN_VERSION"].toString() + val googleHttpClientGsonVersion = extra["GOOGLE_HTTP_CLIENT_GSON_VERSION"].toString() + val googleApiClientAndroidVersion = extra["GOOGLE_API_CLIENT_ANDROID_VERSION"].toString() + val googleApiServicesDriveVersion = extra["GOOGLE_API_SERVICES_DRIVE_VERSION"].toString() + val playServicesAuthVersion = extra["PLAY_SERVICES_AUTH_VERSION"].toString() + // Standalone versions version("flank", flankVersion) version("jacoco", jacocoVersion) version("java", javaVersion) + library("google-http-client-gson", "com.google.http-client:google-http-client-gson:$googleHttpClientGsonVersion") + library("google-api-client-android", "com.google.api-client:google-api-client-android:$googleApiClientAndroidVersion") + library("google-api-services-drive", "com.google.apis:google-api-services-drive:$googleApiServicesDriveVersion") + library("play-services-auth", "com.google.android.gms:play-services-auth:$playServicesAuthVersion") + // Aliases library("accompanist-permissions", "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion") library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion") @@ -211,7 +221,8 @@ dependencyResolutionManagement { library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion") library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion") library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout-compose:$androidxConstraintLayoutVersion") - library("androidx-fragment", "androidx.fragment:fragment-compose:$androidxFragmentVersion") + library("androidx-fragment", "androidx.fragment:fragment:$androidxFragmentVersion") + library("androidx-fragment-compose", "androidx.fragment:fragment-compose:$androidxFragmentVersion") library("androidx-lifecycle-livedata", "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion") library("androidx-lifecycle-compose", "androidx.lifecycle:lifecycle-runtime-compose:$androidxLifecycleVersion") library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion") diff --git a/ui-design-lib/build.gradle.kts b/ui-design-lib/build.gradle.kts index f533bd42..5661331a 100644 --- a/ui-design-lib/build.gradle.kts +++ b/ui-design-lib/build.gradle.kts @@ -41,7 +41,6 @@ dependencies { api(libs.kotlinx.immutable) implementation(libs.zcash.sdk.incubator) implementation(projects.spackleAndroidLib) - api(libs.androidx.fragment) api(libs.lottie) androidTestImplementation(libs.bundles.androidx.test) diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 29b7f1b0..f4e24405 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -108,7 +108,6 @@ dependencies { implementation(libs.androidx.lifecycle.livedata) implementation(libs.androidx.splash) implementation(libs.androidx.workmanager) - api(libs.bundles.androidx.biometric) implementation(libs.androidx.browser) implementation(libs.bundles.androidx.camera) implementation(libs.bundles.androidx.compose.core) @@ -136,6 +135,24 @@ dependencies { api(projects.configurationImplAndroidLib) api(projects.sdkExtLib) api(projects.uiDesignLib) + api(libs.androidx.fragment) + api(libs.androidx.fragment.compose) + api(libs.androidx.activity) + api(libs.google.http.client.gson) { + exclude(group = "io.grpc") + } + api(libs.google.api.client.android) { + exclude(group = "org.apache.httpcomponents") + exclude(group = "io.grpc") + } + api(libs.google.api.services.drive) { + exclude(group = "org.apache.httpcomponents") + exclude(group = "io.grpc") + } + api(libs.play.services.auth) { + exclude(group = "io.grpc") + } + api(libs.bundles.androidx.biometric) androidTestImplementation(projects.testLib) androidTestImplementation(libs.bundles.androidx.test) @@ -159,4 +176,3 @@ dependencies { } } } - diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/DateSourceModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/DateSourceModule.kt index ae18475f..e6c910c8 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/DateSourceModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/DateSourceModule.kt @@ -2,8 +2,8 @@ package co.electriccoin.zcash.di import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSourceImpl -import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookProvider -import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookProviderImpl +import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSource +import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSourceImpl import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module @@ -11,5 +11,5 @@ import org.koin.dsl.module val dataSourceModule = module { singleOf(::LocalAddressBookDataSourceImpl) bind LocalAddressBookDataSource::class - singleOf(::RemoteAddressBookProviderImpl) bind RemoteAddressBookProvider::class + singleOf(::RemoteAddressBookDataSourceImpl) bind RemoteAddressBookDataSource::class } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt index a2c1e7d0..26091b6c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt @@ -1,10 +1,12 @@ package co.electriccoin.zcash.di +import co.electriccoin.zcash.ui.common.provider.AddressBookProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookProviderImpl +import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProviderImpl import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider -import co.electriccoin.zcash.ui.common.provider.LocalAddressBookStorageProvider -import co.electriccoin.zcash.ui.common.provider.LocalAddressBookStorageProviderImpl import org.koin.core.module.dsl.factoryOf import org.koin.dsl.bind import org.koin.dsl.module @@ -14,5 +16,6 @@ val providerModule = factoryOf(::GetDefaultServersProvider) factoryOf(::GetVersionInfoProvider) factoryOf(::GetZcashCurrencyProvider) - factoryOf(::LocalAddressBookStorageProviderImpl) bind LocalAddressBookStorageProvider::class + factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class + factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt index a66c978d..67366340 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package co.electriccoin.zcash.ui import android.annotation.SuppressLint @@ -5,6 +7,7 @@ import android.content.pm.ActivityInfo import android.os.Bundle import android.os.SystemClock import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -30,6 +33,7 @@ import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider import co.electriccoin.zcash.ui.common.extension.setContentCompat import co.electriccoin.zcash.ui.common.model.OnboardingState import co.electriccoin.zcash.ui.common.model.WalletRestoringState +import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel @@ -51,12 +55,18 @@ import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning import co.electriccoin.zcash.ui.screen.support.WrapSupport import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel import co.electriccoin.zcash.work.WorkIds +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.Scopes +import com.google.android.gms.common.api.Scope +import com.google.android.gms.common.api.Status import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -75,6 +85,35 @@ class MainActivity : FragmentActivity() { val configurationOverrideFlow = MutableStateFlow(null) + private val addressBookRepository by inject() + + private val googleSignInLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + RESULT_OK -> { + addressBookRepository.onGoogleSignInSuccess() + } + + RESULT_CANCELED -> { + val status = result.data?.extras?.getParcelable("googleSignInStatus") + addressBookRepository.onGoogleSignInCancelled(status) + } + + else -> { + addressBookRepository.onGoogleSignInError() + } + } + } + + private val googleConsentLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + RESULT_OK -> requestGoogleSignIn() + RESULT_CANCELED -> addressBookRepository.onGoogleSignInCancelled(null) + else -> addressBookRepository.onGoogleSignInError() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -85,6 +124,31 @@ class MainActivity : FragmentActivity() { setupUiContent() monitorForBackgroundSync() + + lifecycleScope.launch { + addressBookRepository.googleSignInRequest.collect { + requestGoogleSignIn() + } + } + + lifecycleScope.launch { + addressBookRepository.googleRemoteConsentRequest.collect { intent -> + googleConsentLauncher.launch(intent) + } + } + } + + private fun requestGoogleSignIn() { + val googleSignInClient = + GoogleSignIn.getClient( + this@MainActivity, + GoogleSignInOptions + .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestScopes(Scope(Scopes.DRIVE_APPFOLDER)) + .build() + ) + + googleSignInLauncher.launch(googleSignInClient.signInIntent) } /** diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt index d9972961..3f97b62d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt @@ -2,15 +2,11 @@ package co.electriccoin.zcash.ui.common.datasource import co.electriccoin.zcash.ui.common.model.AddressBook import co.electriccoin.zcash.ui.common.model.AddressBookContact -import co.electriccoin.zcash.ui.common.provider.LocalAddressBookStorageProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import java.io.FileOutputStream -import java.io.InputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder interface LocalAddressBookDataSource { suspend fun getContacts(): AddressBook @@ -31,17 +27,17 @@ interface LocalAddressBookDataSource { suspend fun saveContacts(contacts: AddressBook) } -@Suppress("TooManyFunctions") class LocalAddressBookDataSourceImpl( - private val localAddressBookStorageProvider: LocalAddressBookStorageProvider + private val addressBookStorageProvider: AddressBookStorageProvider, + private val addressBookProvider: AddressBookProvider ) : LocalAddressBookDataSource { - private var contacts: AddressBook? = null + private var addressBook: AddressBook? = null override suspend fun getContacts(): AddressBook = withContext(Dispatchers.IO) { - val contacts = this@LocalAddressBookDataSourceImpl.contacts + val addressBook = this@LocalAddressBookDataSourceImpl.addressBook - if (contacts == null) { + if (addressBook == null) { var newAddressBook: AddressBook? = readLocalFileToAddressBook() if (newAddressBook == null) { newAddressBook = @@ -54,7 +50,7 @@ class LocalAddressBookDataSourceImpl( } newAddressBook } else { - contacts + addressBook } } @@ -64,20 +60,20 @@ class LocalAddressBookDataSourceImpl( ): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - contacts = + addressBook = AddressBook( lastUpdated = lastUpdated, version = 1, contacts = - contacts?.contacts.orEmpty() + + addressBook?.contacts.orEmpty() + AddressBookContact( name = name, address = address, lastUpdated = lastUpdated, ), ) - writeAddressBookToLocalStorage(contacts!!) - contacts!! + writeAddressBookToLocalStorage(addressBook!!) + addressBook!! } override suspend fun updateContact( @@ -87,12 +83,12 @@ class LocalAddressBookDataSourceImpl( ): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - contacts = + addressBook = AddressBook( lastUpdated = lastUpdated, version = 1, contacts = - contacts?.contacts.orEmpty().toMutableList() + addressBook?.contacts.orEmpty().toMutableList() .apply { set( indexOf(contact), @@ -105,111 +101,40 @@ class LocalAddressBookDataSourceImpl( } .toList(), ) - writeAddressBookToLocalStorage(contacts!!) - contacts!! + writeAddressBookToLocalStorage(addressBook!!) + addressBook!! } override suspend fun deleteContact(addressBookContact: AddressBookContact): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - contacts = + addressBook = AddressBook( lastUpdated = lastUpdated, version = 1, contacts = - contacts?.contacts.orEmpty().toMutableList() + addressBook?.contacts.orEmpty().toMutableList() .apply { remove(addressBookContact) } .toList(), ) - writeAddressBookToLocalStorage(contacts!!) - contacts!! + writeAddressBookToLocalStorage(addressBook!!) + addressBook!! } override suspend fun saveContacts(contacts: AddressBook) { writeAddressBookToLocalStorage(contacts) - this@LocalAddressBookDataSourceImpl.contacts = contacts + this@LocalAddressBookDataSourceImpl.addressBook = contacts } private fun readLocalFileToAddressBook(): AddressBook? { - return localAddressBookStorageProvider.openStorageInputStream()?.let { - deserializeByteArrayFileToAddressBook( - inputStream = it - ) - } + val file = addressBookStorageProvider.getStorageFile() ?: return null + return addressBookProvider.readAddressBookFromFile(file) } private fun writeAddressBookToLocalStorage(addressBook: AddressBook) { - localAddressBookStorageProvider.openStorageOutputStream()?.let { - serializeAddressBookToByteArray( - outputStream = it, - addressBook = addressBook - ) - } - } - - private fun serializeAddressBookToByteArray( - outputStream: FileOutputStream, - addressBook: AddressBook - ) { - outputStream.buffered().use { - it.write(addressBook.version.createByteArray()) - it.write(addressBook.lastUpdated.toEpochMilliseconds().createByteArray()) - it.write(addressBook.contacts.size.createByteArray()) - - addressBook.contacts.forEach { contact -> - it.write(contact.lastUpdated.toEpochMilliseconds().createByteArray()) - it.write(contact.address.createByteArray()) - it.write(contact.name.createByteArray()) - } - } - } - - private fun deserializeByteArrayFileToAddressBook(inputStream: InputStream): AddressBook { - return inputStream.buffered().use { stream -> - AddressBook( - version = stream.readInt(), - lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, - contacts = - stream.readInt().let { contactsSize -> - (0 until contactsSize).map { _ -> - AddressBookContact( - lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, - address = stream.readString(), - name = stream.readString(), - ) - } - } - ) - } - } - - private fun Int.createByteArray(): ByteArray = this.toLong().createByteArray() - - private fun Long.createByteArray(): ByteArray = - ByteBuffer - .allocate(Long.SIZE_BYTES).order(BYTE_ORDER).putLong(this).array() - - private fun String.createByteArray(): ByteArray { - val byteArray = this.toByteArray() - return byteArray.size.createByteArray() + byteArray - } - - private fun InputStream.readInt(): Int = readLong().toInt() - - private fun InputStream.readLong(): Long { - val buffer = ByteArray(Long.SIZE_BYTES) - this.read(buffer) - return ByteBuffer.wrap(buffer).order(BYTE_ORDER).getLong() - } - - private fun InputStream.readString(): String { - val size = this.readInt() - val buffer = ByteArray(size) - this.read(buffer) - return String(buffer) + val file = addressBookStorageProvider.getOrCreateStorageFile() + addressBookProvider.writeAddressBookToFile(file, addressBook) } } - -private val BYTE_ORDER = ByteOrder.BIG_ENDIAN diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookDataSource.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookDataSource.kt new file mode 100644 index 00000000..58f49375 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookDataSource.kt @@ -0,0 +1,197 @@ +@file:Suppress("DEPRECATION") + +package co.electriccoin.zcash.ui.common.datasource + +import android.content.Context +import android.content.Intent +import co.electriccoin.zcash.spackle.Twig +import co.electriccoin.zcash.ui.BuildConfig +import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSource.RemoteConsentResult +import co.electriccoin.zcash.ui.common.model.AddressBook +import co.electriccoin.zcash.ui.common.provider.AddressBookProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider +import com.google.android.gms.auth.UserRecoverableAuthException +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.common.Scopes +import com.google.api.client.extensions.android.http.AndroidHttp +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.googleapis.json.GoogleJsonResponseException +import com.google.api.client.http.FileContent +import com.google.api.client.http.HttpStatusCodes +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import com.google.api.services.drive.model.File as GoogleDriveFile + +interface RemoteAddressBookDataSource { + @Throws( + UserRecoverableAuthException::class, + UserRecoverableAuthIOException::class, + IOException::class, + IllegalArgumentException::class, + GoogleJsonResponseException::class, + ) + suspend fun fetchContacts(): AddressBook? + + @Throws( + UserRecoverableAuthException::class, + UserRecoverableAuthIOException::class, + IOException::class, + IllegalArgumentException::class, + GoogleJsonResponseException::class, + ) + suspend fun uploadContacts() + + suspend fun getRemoteConsent(): RemoteConsentResult + + sealed interface RemoteConsentResult { + data object HasRemoteConsent : RemoteConsentResult + + data class NoRemoteConsent(val intent: Intent?) : RemoteConsentResult + + data object Error : RemoteConsentResult + } +} + +class RemoteAddressBookDataSourceImpl( + private val context: Context, + private val addressBookStorageProvider: AddressBookStorageProvider, + private val addressBookProvider: AddressBookProvider, +) : RemoteAddressBookDataSource { + override suspend fun fetchContacts(): AddressBook? = + withContext(Dispatchers.IO) { + fun fetchRemoteFile(service: Drive): GoogleDriveFile? { + return try { + service.files().list().setSpaces(DRIVE_PRIVATE_APP_FOLDER).execute().files + .find { it.name == DRIVE_ADDRESS_BOOK_FILE_NAME } + } catch (e: GoogleJsonResponseException) { + Twig.info(e) { "No files found on google drive name $DRIVE_ADDRESS_BOOK_FILE_NAME" } + null + } + } + + fun downloadRemoteFile( + service: Drive, + file: GoogleDriveFile + ): File? { + return try { + val localFile = addressBookStorageProvider.getOrCreateTempStorageFile() + + localFile.outputStream().use { outputStream -> + service.files().get(file.id).executeMediaAndDownloadTo(outputStream) + } + + localFile + } catch (e: GoogleJsonResponseException) { + Twig.info(e) { "No files found on google drive name $DRIVE_ADDRESS_BOOK_FILE_NAME" } + null + } + } + + var localTempFile: File? = null + + return@withContext try { + val drive = createGoogleDriveService() + val remoteFile = fetchRemoteFile(drive) + + if (remoteFile == null) { + Twig.info { "No address book file found to upload" } + return@withContext null + } + + localTempFile = downloadRemoteFile(drive, remoteFile) ?: return@withContext null + + addressBookProvider.readAddressBookFromFile(localTempFile) + } finally { + localTempFile?.delete() + } + } + + override suspend fun uploadContacts() = + withContext(Dispatchers.IO) { + fun deleteExistingRemoteFiles(service: Drive) { + try { + val files = + service.files().list().setSpaces(DRIVE_PRIVATE_APP_FOLDER).execute().files + .filter { it.name == DRIVE_ADDRESS_BOOK_FILE_NAME } + files.forEach { + service.files().delete(it.id).execute() + } + } catch (e: GoogleJsonResponseException) { + if (e.statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { + Twig.info(e) { "No files found on google drive name $DRIVE_ADDRESS_BOOK_FILE_NAME" } + } else { + throw e + } + } + } + + fun createRemoteFile( + file: File, + service: Drive + ) { + val metadata = + GoogleDriveFile() + .setParents(listOf(DRIVE_PRIVATE_APP_FOLDER)) + .setMimeType("application/octet-stream") + .setName(file.name) + val fileContent = FileContent("application/octet-stream", file) + + service.files().create(metadata, fileContent).execute() + } + + val drive = createGoogleDriveService() + val localFile = addressBookStorageProvider.getStorageFile() + + if (localFile == null) { + Twig.info { "No address book file found to upload" } + return@withContext + } + + deleteExistingRemoteFiles(drive) + createRemoteFile(localFile, drive) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override suspend fun getRemoteConsent(): RemoteConsentResult = + withContext(Dispatchers.IO) { + val drive = createGoogleDriveService() + + try { + drive.files().list().setSpaces(DRIVE_PRIVATE_APP_FOLDER).execute() + RemoteConsentResult.HasRemoteConsent + } catch (e: UserRecoverableAuthException) { + RemoteConsentResult.NoRemoteConsent(e.intent) + } catch (e: UserRecoverableAuthIOException) { + RemoteConsentResult.NoRemoteConsent(e.intent) + } catch (e: Exception) { + RemoteConsentResult.Error + } + } + + private fun createGoogleDriveService(): Drive { + val account = GoogleSignIn.getLastSignedInAccount(context) + + val credentials = + GoogleAccountCredential.usingOAuth2(context, listOf(Scopes.DRIVE_APPFOLDER)) + .apply { + selectedAccount = account?.account ?: allAccounts.firstOrNull() + } + + return Drive + .Builder( + AndroidHttp.newCompatibleTransport(), + GsonFactory(), + credentials + ) + .setApplicationName(if (BuildConfig.DEBUG) "secant-android-debug" else "Zashi") + .build() + } +} + +private const val DRIVE_PRIVATE_APP_FOLDER = "appDataFolder" +private const val DRIVE_ADDRESS_BOOK_FILE_NAME = "address_book" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookProvider.kt deleted file mode 100644 index bad17fdb..00000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/RemoteAddressBookProvider.kt +++ /dev/null @@ -1,22 +0,0 @@ -package co.electriccoin.zcash.ui.common.datasource - -import co.electriccoin.zcash.ui.common.model.AddressBook - -interface RemoteAddressBookProvider { - suspend fun fetchContacts(): AddressBook? - - suspend fun uploadContacts() -} - -class RemoteAddressBookProviderImpl : RemoteAddressBookProvider { - override suspend fun fetchContacts(): AddressBook? { - // fetch - // deserialize - return null - } - - override suspend fun uploadContacts() { - // localAddressBookStorageProvider.openStorageInputStream() // read - // upload file stream - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt new file mode 100644 index 00000000..2906bff4 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt @@ -0,0 +1,100 @@ +package co.electriccoin.zcash.ui.common.provider + +import co.electriccoin.zcash.ui.common.model.AddressBook +import co.electriccoin.zcash.ui.common.model.AddressBookContact +import kotlinx.datetime.Instant +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +interface AddressBookProvider { + fun writeAddressBookToFile( + file: File, + addressBook: AddressBook + ) + + fun readAddressBookFromFile(file: File): AddressBook +} + +class AddressBookProviderImpl : AddressBookProvider { + override fun writeAddressBookToFile( + file: File, + addressBook: AddressBook + ) { + file.outputStream().use { + serializeAddressBookToByteArrayFile(it, addressBook) + } + } + + override fun readAddressBookFromFile(file: File): AddressBook { + return file.inputStream().use { + deserializeByteArrayFileToAddressBook(it) + } + } + + private fun serializeAddressBookToByteArrayFile( + outputStream: FileOutputStream, + addressBook: AddressBook + ) { + outputStream.buffered().use { + it.write(addressBook.version.createByteArray()) + it.write(addressBook.lastUpdated.toEpochMilliseconds().createByteArray()) + it.write(addressBook.contacts.size.createByteArray()) + + addressBook.contacts.forEach { contact -> + it.write(contact.lastUpdated.toEpochMilliseconds().createByteArray()) + it.write(contact.address.createByteArray()) + it.write(contact.name.createByteArray()) + } + } + } + + private fun deserializeByteArrayFileToAddressBook(inputStream: InputStream): AddressBook { + return inputStream.buffered().use { stream -> + AddressBook( + version = stream.readInt(), + lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, + contacts = + stream.readInt().let { contactsSize -> + (0 until contactsSize).map { _ -> + AddressBookContact( + lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, + address = stream.readString(), + name = stream.readString(), + ) + } + } + ) + } + } + + private fun Int.createByteArray(): ByteArray = this.toLong().createByteArray() + + private fun Long.createByteArray(): ByteArray = + ByteBuffer + .allocate(Long.SIZE_BYTES).order(BYTE_ORDER).putLong(this).array() + + private fun String.createByteArray(): ByteArray { + val byteArray = this.toByteArray() + return byteArray.size.createByteArray() + byteArray + } + + private fun InputStream.readInt(): Int = readLong().toInt() + + private fun InputStream.readLong(): Long { + val buffer = ByteArray(Long.SIZE_BYTES) + this.read(buffer) + return ByteBuffer.wrap(buffer).order(BYTE_ORDER).getLong() + } + + private fun InputStream.readString(): String { + val size = this.readInt() + val buffer = ByteArray(size) + this.read(buffer) + return String(buffer) + } +} + +private val BYTE_ORDER = ByteOrder.BIG_ENDIAN diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt new file mode 100644 index 00000000..52b6a98e --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt @@ -0,0 +1,39 @@ +package co.electriccoin.zcash.ui.common.provider + +import android.content.Context +import java.io.File + +interface AddressBookStorageProvider { + fun getStorageFile(): File? + + fun getOrCreateStorageFile(): File + + /** + * Create a temporary file into which data from remote is written. This file is removed after usage. + */ + fun getOrCreateTempStorageFile(): File +} + +class AddressBookStorageProviderImpl( + private val context: Context +) : AddressBookStorageProvider { + override fun getStorageFile(): File? { + return File(context.noBackupFilesDir, LOCAL_ADDRESS_BOOK_FILE_NAME) + .takeIf { it.exists() && it.isFile } + } + + override fun getOrCreateStorageFile(): File = getOrCreateFile(LOCAL_ADDRESS_BOOK_FILE_NAME) + + override fun getOrCreateTempStorageFile(): File = getOrCreateFile(REMOTE_ADDRESS_BOOK_FILE_NAME_LOCAL_COPY) + + private fun getOrCreateFile(name: String): File { + val file = File(context.noBackupFilesDir, name) + if (!file.exists()) { + file.createNewFile() + } + return file + } +} + +private const val LOCAL_ADDRESS_BOOK_FILE_NAME = "address_book" +private const val REMOTE_ADDRESS_BOOK_FILE_NAME_LOCAL_COPY = "address_book_temp" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/LocalAddressBookStorageProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/LocalAddressBookStorageProvider.kt deleted file mode 100644 index e976c974..00000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/LocalAddressBookStorageProvider.kt +++ /dev/null @@ -1,44 +0,0 @@ -package co.electriccoin.zcash.ui.common.provider - -import android.content.Context -import co.electriccoin.zcash.spackle.Twig -import java.io.FileInputStream -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.IOException - -interface LocalAddressBookStorageProvider { - fun openStorageInputStream(): FileInputStream? - - fun openStorageOutputStream(): FileOutputStream? -} - -class LocalAddressBookStorageProviderImpl( - private val context: Context -) : LocalAddressBookStorageProvider { - override fun openStorageInputStream(): FileInputStream? { - return try { - context.openFileInput(LOCAL_ADDRESS_BOOK_FILE_NAME) - } catch (e: FileNotFoundException) { - Twig.error(e) { "Address Book file does not exist yet" } - null - } catch (e: IOException) { - Twig.error(e) { "Error reading from Address Book file" } - null - } - } - - override fun openStorageOutputStream(): FileOutputStream? { - return try { - context.openFileOutput(LOCAL_ADDRESS_BOOK_FILE_NAME, Context.MODE_PRIVATE) - } catch (e: FileNotFoundException) { - Twig.error(e) { "Address Book file does not exist yet" } - null - } catch (e: IOException) { - Twig.error(e) { "Error writing to Address Book file" } - null - } - } -} - -private const val LOCAL_ADDRESS_BOOK_FILE_NAME = "address_book" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt index 525507b7..ccdf3a8f 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt @@ -1,22 +1,52 @@ +@file:Suppress("DEPRECATION") + package co.electriccoin.zcash.ui.common.repository +import android.content.Context +import android.content.Intent +import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource -import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookProvider +import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSource +import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSource.RemoteConsentResult import co.electriccoin.zcash.ui.common.model.AddressBook import co.electriccoin.zcash.ui.common.model.AddressBookContact +import com.google.android.gms.auth.GoogleAuthException +import com.google.android.gms.auth.UserRecoverableAuthException +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.common.Scopes +import com.google.android.gms.common.api.Scope +import com.google.android.gms.common.api.Status +import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException +import com.google.api.client.googleapis.json.GoogleJsonResponseException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import java.io.IOException +import kotlin.math.max +import kotlin.time.Duration.Companion.seconds interface AddressBookRepository { val addressBook: Flow + val googleSignInRequest: Flow + + val googleRemoteConsentRequest: Flow + suspend fun saveContact( name: String, address: String @@ -29,15 +59,28 @@ interface AddressBookRepository { ) suspend fun deleteContact(contact: AddressBookContact) + + fun onGoogleSignInSuccess() + + fun onGoogleSignInCancelled(status: Status?) + + fun onGoogleSignInError() } +@Suppress("TooManyFunctions") class AddressBookRepositoryImpl( private val localAddressBookDataSource: LocalAddressBookDataSource, - private val remoteAddressBookProvider: RemoteAddressBookProvider + private val remoteAddressBookDataSource: RemoteAddressBookDataSource, + private val context: Context ) : AddressBookRepository { + private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private val semaphore = Mutex() + private val addressBookCache = MutableStateFlow(null) + private var internalOperation: InternalOperation? = null + override val addressBook: Flow = addressBookCache .onSubscription { @@ -45,62 +88,232 @@ class AddressBookRepositoryImpl( ensureSynchronization() } } + .stateIn(scope = scope, started = SharingStarted.WhileSubscribed(60.seconds), initialValue = null) + + override val googleSignInRequest = MutableSharedFlow() + + override val googleRemoteConsentRequest = MutableSharedFlow() + + private val internalOperationCompleted = MutableSharedFlow() override suspend fun saveContact( name: String, address: String - ) = withNonCancellableSemaphore { - ensureSynchronization() - val local = localAddressBookDataSource.saveContact(name, address) - addressBookCache.update { local } - remoteAddressBookProvider.uploadContacts() - } + ) = withGoogleDrivePermission(InternalOperation.Save(name = name, address = address)) override suspend fun updateContact( contact: AddressBookContact, name: String, address: String - ) { - withNonCancellableSemaphore { - ensureSynchronization() - val local = localAddressBookDataSource.updateContact(contact, name, address) - addressBookCache.update { local } - remoteAddressBookProvider.uploadContacts() + ) = withGoogleDrivePermission(InternalOperation.Update(contact = contact, name = name, address = address)) + + override suspend fun deleteContact(contact: AddressBookContact) = + withGoogleDrivePermission( + InternalOperation.Delete(contact = contact) + ) + + override fun onGoogleSignInSuccess() { + scope.launch { + withNonCancellableSemaphore { + internalOperation?.let { + Twig.info { "Google sign in success" } + executeInternalOperation(operation = it) + this@AddressBookRepositoryImpl.internalOperation = null + internalOperationCompleted.emit(it) + } + } } } - override suspend fun deleteContact(contact: AddressBookContact) = - withNonCancellableSemaphore { - ensureSynchronization() - val local = localAddressBookDataSource.deleteContact(contact) - addressBookCache.update { local } - remoteAddressBookProvider.uploadContacts() + override fun onGoogleSignInCancelled(status: Status?) { + scope.launch { + withNonCancellableSemaphore { + Twig.info { "Google sign in cancelled, $status" } + internalOperation?.let { + executeInternalOperation(operation = it) + this@AddressBookRepositoryImpl.internalOperation = null + internalOperationCompleted.emit(it) + } + } } + } - private suspend fun ensureSynchronization() { - if (addressBookCache.value == null) { + override fun onGoogleSignInError() { + scope.launch { + withNonCancellableSemaphore { + internalOperation?.let { + Twig.info { "Address Book: onGoogleSignInError" } + executeInternalOperation(operation = it) + this@AddressBookRepositoryImpl.internalOperation = null + internalOperationCompleted.emit(it) + } + } + } + } + + private suspend fun ensureSynchronization( + forceUpdate: Boolean = false, + operation: InternalOperation? = null + ) { + if (forceUpdate || addressBookCache.value == null) { + val remote = + executeRemoteAddressBookSafe { + val contacts = remoteAddressBookDataSource.fetchContacts() + Twig.info { "Address Book: ensureSynchronization - remote address book loaded" } + contacts + } val merged = mergeContacts( local = localAddressBookDataSource.getContacts(), - remote = remoteAddressBookProvider.fetchContacts(), + remote = remote, + fromOperation = operation ) - localAddressBookDataSource.saveContacts(merged) - remoteAddressBookProvider.uploadContacts() - + executeRemoteAddressBookSafe { + remoteAddressBookDataSource.uploadContacts() + Twig.info { "Address Book: ensureSynchronization - remote address book uploaded" } + } addressBookCache.update { merged } } } - @Suppress("UNUSED_PARAMETER") private fun mergeContacts( local: AddressBook, - remote: AddressBook? - ): AddressBook = local // TBD + remote: AddressBook?, + fromOperation: InternalOperation? + ): AddressBook { + if (remote == null) return local + + val allContacts = + if (fromOperation is InternalOperation.Delete) { + (local.contacts + remote.contacts).toMutableList() + .apply { + removeAll { it.address == fromOperation.contact.address } + } + .toList() + } else { + local.contacts + remote.contacts + } + + return AddressBook( + lastUpdated = Clock.System.now(), + version = max(local.version, remote.version), + contacts = + allContacts + .groupBy { it.address } + .map { (_, contacts) -> + contacts.maxBy { it.lastUpdated } + } + ) + } + + private suspend fun withGoogleDrivePermission(internalOperation: InternalOperation) { + val remoteConsent = getRemoteConsent() + + if (hasGoogleDrivePermission() && remoteConsent in + listOf(RemoteConsentResult.HasRemoteConsent, RemoteConsentResult.Error) + ) { + withNonCancellableSemaphore { + executeInternalOperation(operation = internalOperation) + } + } else { + withNonCancellableSemaphore { + if (remoteConsent is RemoteConsentResult.NoRemoteConsent && remoteConsent.intent != null) { + Twig.info { "Address Book: withGoogleDrivePermission - request consent" } + this.internalOperation = internalOperation + googleRemoteConsentRequest.emit(remoteConsent.intent) + } else { + Twig.info { "Address Book: withGoogleDrivePermission - request permission" } + this.internalOperation = internalOperation + googleSignInRequest.emit(Unit) + } + } + internalOperationCompleted.first { it == internalOperation } + } + } + + private suspend fun hasGoogleDrivePermission() = + withContext(Dispatchers.IO) { + GoogleSignIn.hasPermissions(GoogleSignIn.getLastSignedInAccount(context), Scope(GOOGLE_DRIVE_SCOPE)) + } + + private suspend fun getRemoteConsent() = remoteAddressBookDataSource.getRemoteConsent() + + private suspend fun executeInternalOperation(operation: InternalOperation) { + val local = + when (operation) { + is InternalOperation.Delete -> { + Twig.info { "Address Book: executeInternalOperation - delete" } + localAddressBookDataSource.deleteContact(addressBookContact = operation.contact) + } + + is InternalOperation.Save -> { + Twig.info { "Address Book: executeInternalOperation - save" } + localAddressBookDataSource.saveContact(name = operation.name, address = operation.address) + } + + is InternalOperation.Update -> { + Twig.info { "Address Book: executeInternalOperation - update" } + localAddressBookDataSource.updateContact( + contact = operation.contact, + name = operation.name, + address = operation.address + ) + } + } + addressBookCache.update { local } + scope.launch { + withNonCancellableSemaphore { + ensureSynchronization(forceUpdate = true, operation = operation) + } + } + } private suspend fun withNonCancellableSemaphore(block: suspend () -> Unit) { withContext(NonCancellable + Dispatchers.Default) { semaphore.withLock { block() } } } + + @Suppress("TooGenericExceptionCaught") + private suspend fun executeRemoteAddressBookSafe(block: suspend () -> T): T? { + if (hasGoogleDrivePermission().not()) { + return null + } + + return try { + block() + } catch (e: UserRecoverableAuthException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: UserRecoverableAuthIOException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: GoogleAuthException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: GoogleJsonResponseException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: IOException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: IllegalArgumentException) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } catch (e: Exception) { + Twig.error(e) { "Address Book: remote execution failed" } + null + } + } } + +private sealed interface InternalOperation { + data class Save(val name: String, val address: String) : InternalOperation + + data class Update(val contact: AddressBookContact, val name: String, val address: String) : InternalOperation + + data class Delete(val contact: AddressBookContact) : InternalOperation +} + +private const val GOOGLE_DRIVE_SCOPE = Scopes.DRIVE_APPFOLDER diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/viewmodel/TransactionHistoryViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/viewmodel/TransactionHistoryViewModel.kt index 0d361031..db408680 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/viewmodel/TransactionHistoryViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/viewmodel/TransactionHistoryViewModel.kt @@ -8,7 +8,8 @@ import cash.z.ecc.android.sdk.model.FirstClassByteArray import cash.z.ecc.android.sdk.model.TransactionOverview import cash.z.ecc.android.sdk.model.TransactionRecipient import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT -import co.electriccoin.zcash.ui.common.usecase.GetContactByAddressUseCase +import co.electriccoin.zcash.ui.common.model.AddressBookContact +import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt import co.electriccoin.zcash.ui.screen.account.model.TransactionUi import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState @@ -17,22 +18,29 @@ import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class TransactionHistoryViewModel( - private val getContactByAddress: GetContactByAddressUseCase + private val observeAddressBookContacts: ObserveAddressBookContactsUseCase ) : ViewModel() { private val state: MutableStateFlow = MutableStateFlow(State.LOADING) private val transactions: MutableStateFlow> = MutableStateFlow(persistentListOf()) + private var transactionSyncJob: Job? = null + val transactionUiState: StateFlow = state.combine(transactions) { state: State, transactions: ImmutableList -> when (state) { @@ -48,39 +56,73 @@ class TransactionHistoryViewModel( TransactionUiState.Loading ) - fun processTransactionState(dataState: TransactionHistorySyncState) = - viewModelScope.launch { - when (dataState) { - TransactionHistorySyncState.Loading -> { - state.value = State.LOADING - transactions.value = persistentListOf() - } - is TransactionHistorySyncState.Syncing -> { - if (dataState.transactions.isEmpty()) { - state.value = State.SYNCING_EMPTY - } else { - state.value = State.SYNCING - transactions.value = - dataState.transactions - .map { data -> getOrUpdateTransactionItem(data) } - .toPersistentList() + fun processTransactionState(dataState: TransactionHistorySyncState) { + transactionSyncJob?.cancel() + transactionSyncJob = + viewModelScope.launch { + when (dataState) { + TransactionHistorySyncState.Loading -> { + state.value = State.LOADING + transactions.value = persistentListOf() } - } - is TransactionHistorySyncState.Done -> { - if (dataState.transactions.isEmpty()) { - state.value = State.DONE_EMPTY - } else { - state.value = State.DONE - transactions.value = - dataState.transactions - .map { data -> getOrUpdateTransactionItem(data) } - .toPersistentList() + + is TransactionHistorySyncState.Syncing -> { + if (dataState.transactions.isEmpty()) { + state.value = State.SYNCING_EMPTY + } else { + state.value = State.SYNCING + + observeAddressBookContacts() + .map { contacts -> + dataState.transactions + .map { data -> + val contact = + contacts?.find { contact -> + contact.address == + (data.recipient as? TransactionRecipient.Address) + ?.addressValue + } + getOrUpdateTransactionItem(data, contact) + } + .toPersistentList() + } + .onEach { new -> transactions.update { new } } + .launchIn(this) + } + } + + is TransactionHistorySyncState.Done -> { + if (dataState.transactions.isEmpty()) { + state.value = State.DONE_EMPTY + } else { + state.value = State.DONE + + observeAddressBookContacts() + .map { contacts -> + dataState.transactions + .map { data -> + val contact = + contacts?.find { contact -> + contact.address == + (data.recipient as? TransactionRecipient.Address) + ?.addressValue + } + getOrUpdateTransactionItem(data, contact) + } + .toPersistentList() + } + .onEach { new -> transactions.update { new } } + .launchIn(this) + } } } } - } + } - private suspend fun getOrUpdateTransactionItem(data: TransactionOverviewExt): TransactionUi { + private fun getOrUpdateTransactionItem( + data: TransactionOverviewExt, + addressBookContact: AddressBookContact? + ): TransactionUi { val existingTransaction = transactions.value.find { data.overview.rawId == it.overview.rawId @@ -89,10 +131,7 @@ class TransactionHistoryViewModel( data = data, expandableState = existingTransaction?.expandableState ?: TrxItemState.COLLAPSED, messages = existingTransaction?.messages, - addressBookContact = - (data.recipient as? TransactionRecipient.Address)?.addressValue?.let { - getContactByAddress(it) - } + addressBookContact = addressBookContact ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt index ee0b0017..06962bd4 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt @@ -44,7 +44,7 @@ class AddressBookViewModel( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - initialValue = createState(contacts = emptyList()) + initialValue = createState(contacts = null) ) val navigationCommand = MutableSharedFlow() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt index 1ce2d9ea..b6615563 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt @@ -40,6 +40,7 @@ class AddContactViewModel( when (validateContactAddress(address)) { ValidateContactAddressUseCase.Result.Invalid -> stringRes(R.string.contact_address_error_invalid) + ValidateContactAddressUseCase.Result.NotUnique -> stringRes(R.string.contact_address_error_not_unique) @@ -141,6 +142,8 @@ class AddContactViewModel( private fun onSaveButtonClick() = viewModelScope.launch { + if (isSavingContact.value) return@launch + isSavingContact.update { true } saveContact(name = contactName.value, address = contactAddress.value) backNavigationCommand.emit(Unit) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt index f0fa73f3..a9185e6c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt @@ -173,6 +173,7 @@ class UpdateContactViewModel( private fun onUpdateButtonClick() = viewModelScope.launch { + if (isDeletingContact.value || isUpdatingContact.value) return@launch contact?.let { isUpdatingContact.update { true } updateContact(contact = it, name = contactName.value, address = contactAddress.value) @@ -183,6 +184,7 @@ class UpdateContactViewModel( private fun onDeleteButtonClick() = viewModelScope.launch { + if (isDeletingContact.value || isUpdatingContact.value) return@launch contact?.let { isDeletingContact.update { true } deleteContact(it)