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
This commit is contained in:
parent
4d0c04f93b
commit
624bee88ef
|
@ -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
|
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Address book local and remote storage support
|
||||||
- New QR Code detail screen has been added
|
- New QR Code detail screen has been added
|
||||||
|
|
||||||
## [1.2 (739)] - 2024-09-27
|
## [1.2 (739)] - 2024-09-27
|
||||||
|
|
|
@ -23,6 +23,16 @@
|
||||||
-dontwarn javax.naming.NamingEnumeration
|
-dontwarn javax.naming.NamingEnumeration
|
||||||
-dontwarn javax.naming.NamingException
|
-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
|
# 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.
|
# in the projects, so the classes aren't present. These warnings are safe to suppress.
|
||||||
-dontwarn kotlinx.serialization.KSerializer
|
-dontwarn kotlinx.serialization.KSerializer
|
||||||
|
|
|
@ -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
|
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Address book local and remote storage support
|
||||||
- New QR Code detail screen has been added
|
- New QR Code detail screen has been added
|
||||||
|
|
||||||
## [1.2 (739)] - 2024-09-27
|
## [1.2 (739)] - 2024-09-27
|
||||||
|
|
|
@ -162,7 +162,7 @@ KTLINT_VERSION=1.2.1
|
||||||
KOIN_VERSION=3.5.6
|
KOIN_VERSION=3.5.6
|
||||||
|
|
||||||
ACCOMPANIST_PERMISSIONS_VERSION=0.34.0
|
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_ANNOTATION_VERSION=1.7.1
|
||||||
ANDROIDX_BIOMETRIC_VERSION=1.2.0-alpha05
|
ANDROIDX_BIOMETRIC_VERSION=1.2.0-alpha05
|
||||||
ANDROIDX_CAMERA_VERSION=1.3.2
|
ANDROIDX_CAMERA_VERSION=1.3.2
|
||||||
|
@ -174,7 +174,7 @@ ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.1
|
||||||
ANDROIDX_CORE_VERSION=1.12.0
|
ANDROIDX_CORE_VERSION=1.12.0
|
||||||
ANDROIDX_ESPRESSO_VERSION=3.5.1
|
ANDROIDX_ESPRESSO_VERSION=3.5.1
|
||||||
ANDROIDX_LIFECYCLE_VERSION=2.7.0
|
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_NAVIGATION_COMPOSE_VERSION=2.7.7
|
||||||
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.1
|
ANDROIDX_PROFILE_INSTALLER_VERSION=1.3.1
|
||||||
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha06
|
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
|
PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0
|
||||||
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
|
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
|
||||||
ZXING_VERSION=3.5.3
|
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
|
ZCASH_BIP39_VERSION=1.0.8
|
||||||
|
|
||||||
|
|
|
@ -185,12 +185,22 @@ dependencyResolutionManagement {
|
||||||
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
|
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
|
||||||
val zxingVersion = extra["ZXING_VERSION"].toString()
|
val zxingVersion = extra["ZXING_VERSION"].toString()
|
||||||
val koinVersion = extra["KOIN_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
|
// Standalone versions
|
||||||
version("flank", flankVersion)
|
version("flank", flankVersion)
|
||||||
version("jacoco", jacocoVersion)
|
version("jacoco", jacocoVersion)
|
||||||
version("java", javaVersion)
|
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
|
// Aliases
|
||||||
library("accompanist-permissions", "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion")
|
library("accompanist-permissions", "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion")
|
||||||
library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion")
|
library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion")
|
||||||
|
@ -211,7 +221,8 @@ dependencyResolutionManagement {
|
||||||
library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
|
library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
|
||||||
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
|
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
|
||||||
library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout-compose:$androidxConstraintLayoutVersion")
|
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-livedata", "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
|
||||||
library("androidx-lifecycle-compose", "androidx.lifecycle:lifecycle-runtime-compose:$androidxLifecycleVersion")
|
library("androidx-lifecycle-compose", "androidx.lifecycle:lifecycle-runtime-compose:$androidxLifecycleVersion")
|
||||||
library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion")
|
library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion")
|
||||||
|
|
|
@ -41,7 +41,6 @@ dependencies {
|
||||||
api(libs.kotlinx.immutable)
|
api(libs.kotlinx.immutable)
|
||||||
implementation(libs.zcash.sdk.incubator)
|
implementation(libs.zcash.sdk.incubator)
|
||||||
implementation(projects.spackleAndroidLib)
|
implementation(projects.spackleAndroidLib)
|
||||||
api(libs.androidx.fragment)
|
|
||||||
api(libs.lottie)
|
api(libs.lottie)
|
||||||
|
|
||||||
androidTestImplementation(libs.bundles.androidx.test)
|
androidTestImplementation(libs.bundles.androidx.test)
|
||||||
|
|
|
@ -108,7 +108,6 @@ dependencies {
|
||||||
implementation(libs.androidx.lifecycle.livedata)
|
implementation(libs.androidx.lifecycle.livedata)
|
||||||
implementation(libs.androidx.splash)
|
implementation(libs.androidx.splash)
|
||||||
implementation(libs.androidx.workmanager)
|
implementation(libs.androidx.workmanager)
|
||||||
api(libs.bundles.androidx.biometric)
|
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
implementation(libs.bundles.androidx.camera)
|
implementation(libs.bundles.androidx.camera)
|
||||||
implementation(libs.bundles.androidx.compose.core)
|
implementation(libs.bundles.androidx.compose.core)
|
||||||
|
@ -136,6 +135,24 @@ dependencies {
|
||||||
api(projects.configurationImplAndroidLib)
|
api(projects.configurationImplAndroidLib)
|
||||||
api(projects.sdkExtLib)
|
api(projects.sdkExtLib)
|
||||||
api(projects.uiDesignLib)
|
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(projects.testLib)
|
||||||
androidTestImplementation(libs.bundles.androidx.test)
|
androidTestImplementation(libs.bundles.androidx.test)
|
||||||
|
@ -159,4 +176,3 @@ dependencies {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ package co.electriccoin.zcash.di
|
||||||
|
|
||||||
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource
|
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource
|
||||||
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSourceImpl
|
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSourceImpl
|
||||||
import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookProvider
|
import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSource
|
||||||
import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookProviderImpl
|
import co.electriccoin.zcash.ui.common.datasource.RemoteAddressBookDataSourceImpl
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
@ -11,5 +11,5 @@ import org.koin.dsl.module
|
||||||
val dataSourceModule =
|
val dataSourceModule =
|
||||||
module {
|
module {
|
||||||
singleOf(::LocalAddressBookDataSourceImpl) bind LocalAddressBookDataSource::class
|
singleOf(::LocalAddressBookDataSourceImpl) bind LocalAddressBookDataSource::class
|
||||||
singleOf(::RemoteAddressBookProviderImpl) bind RemoteAddressBookProvider::class
|
singleOf(::RemoteAddressBookDataSourceImpl) bind RemoteAddressBookDataSource::class
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
package co.electriccoin.zcash.di
|
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.GetDefaultServersProvider
|
||||||
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
|
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
|
||||||
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
|
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.core.module.dsl.factoryOf
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
@ -14,5 +16,6 @@ val providerModule =
|
||||||
factoryOf(::GetDefaultServersProvider)
|
factoryOf(::GetDefaultServersProvider)
|
||||||
factoryOf(::GetVersionInfoProvider)
|
factoryOf(::GetVersionInfoProvider)
|
||||||
factoryOf(::GetZcashCurrencyProvider)
|
factoryOf(::GetZcashCurrencyProvider)
|
||||||
factoryOf(::LocalAddressBookStorageProviderImpl) bind LocalAddressBookStorageProvider::class
|
factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class
|
||||||
|
factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package co.electriccoin.zcash.ui
|
package co.electriccoin.zcash.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
@ -5,6 +7,7 @@ import android.content.pm.ActivityInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.extension.setContentCompat
|
||||||
import co.electriccoin.zcash.ui.common.model.OnboardingState
|
import co.electriccoin.zcash.ui.common.model.OnboardingState
|
||||||
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
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.AuthenticationUIState
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
|
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.support.WrapSupport
|
||||||
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
|
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
|
||||||
import co.electriccoin.zcash.work.WorkIds
|
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.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
@ -75,6 +85,35 @@ class MainActivity : FragmentActivity() {
|
||||||
|
|
||||||
val configurationOverrideFlow = MutableStateFlow<ConfigurationOverride?>(null)
|
val configurationOverrideFlow = MutableStateFlow<ConfigurationOverride?>(null)
|
||||||
|
|
||||||
|
private val addressBookRepository by inject<AddressBookRepositoryImpl>()
|
||||||
|
|
||||||
|
private val googleSignInLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
when (result.resultCode) {
|
||||||
|
RESULT_OK -> {
|
||||||
|
addressBookRepository.onGoogleSignInSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
RESULT_CANCELED -> {
|
||||||
|
val status = result.data?.extras?.getParcelable<Status>("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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
@ -85,6 +124,31 @@ class MainActivity : FragmentActivity() {
|
||||||
setupUiContent()
|
setupUiContent()
|
||||||
|
|
||||||
monitorForBackgroundSync()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.AddressBook
|
||||||
import co.electriccoin.zcash.ui.common.model.AddressBookContact
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.Clock
|
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 {
|
interface LocalAddressBookDataSource {
|
||||||
suspend fun getContacts(): AddressBook
|
suspend fun getContacts(): AddressBook
|
||||||
|
@ -31,17 +27,17 @@ interface LocalAddressBookDataSource {
|
||||||
suspend fun saveContacts(contacts: AddressBook)
|
suspend fun saveContacts(contacts: AddressBook)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("TooManyFunctions")
|
|
||||||
class LocalAddressBookDataSourceImpl(
|
class LocalAddressBookDataSourceImpl(
|
||||||
private val localAddressBookStorageProvider: LocalAddressBookStorageProvider
|
private val addressBookStorageProvider: AddressBookStorageProvider,
|
||||||
|
private val addressBookProvider: AddressBookProvider
|
||||||
) : LocalAddressBookDataSource {
|
) : LocalAddressBookDataSource {
|
||||||
private var contacts: AddressBook? = null
|
private var addressBook: AddressBook? = null
|
||||||
|
|
||||||
override suspend fun getContacts(): AddressBook =
|
override suspend fun getContacts(): AddressBook =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val contacts = this@LocalAddressBookDataSourceImpl.contacts
|
val addressBook = this@LocalAddressBookDataSourceImpl.addressBook
|
||||||
|
|
||||||
if (contacts == null) {
|
if (addressBook == null) {
|
||||||
var newAddressBook: AddressBook? = readLocalFileToAddressBook()
|
var newAddressBook: AddressBook? = readLocalFileToAddressBook()
|
||||||
if (newAddressBook == null) {
|
if (newAddressBook == null) {
|
||||||
newAddressBook =
|
newAddressBook =
|
||||||
|
@ -54,7 +50,7 @@ class LocalAddressBookDataSourceImpl(
|
||||||
}
|
}
|
||||||
newAddressBook
|
newAddressBook
|
||||||
} else {
|
} else {
|
||||||
contacts
|
addressBook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,20 +60,20 @@ class LocalAddressBookDataSourceImpl(
|
||||||
): AddressBook =
|
): AddressBook =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val lastUpdated = Clock.System.now()
|
val lastUpdated = Clock.System.now()
|
||||||
contacts =
|
addressBook =
|
||||||
AddressBook(
|
AddressBook(
|
||||||
lastUpdated = lastUpdated,
|
lastUpdated = lastUpdated,
|
||||||
version = 1,
|
version = 1,
|
||||||
contacts =
|
contacts =
|
||||||
contacts?.contacts.orEmpty() +
|
addressBook?.contacts.orEmpty() +
|
||||||
AddressBookContact(
|
AddressBookContact(
|
||||||
name = name,
|
name = name,
|
||||||
address = address,
|
address = address,
|
||||||
lastUpdated = lastUpdated,
|
lastUpdated = lastUpdated,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
writeAddressBookToLocalStorage(contacts!!)
|
writeAddressBookToLocalStorage(addressBook!!)
|
||||||
contacts!!
|
addressBook!!
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateContact(
|
override suspend fun updateContact(
|
||||||
|
@ -87,12 +83,12 @@ class LocalAddressBookDataSourceImpl(
|
||||||
): AddressBook =
|
): AddressBook =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val lastUpdated = Clock.System.now()
|
val lastUpdated = Clock.System.now()
|
||||||
contacts =
|
addressBook =
|
||||||
AddressBook(
|
AddressBook(
|
||||||
lastUpdated = lastUpdated,
|
lastUpdated = lastUpdated,
|
||||||
version = 1,
|
version = 1,
|
||||||
contacts =
|
contacts =
|
||||||
contacts?.contacts.orEmpty().toMutableList()
|
addressBook?.contacts.orEmpty().toMutableList()
|
||||||
.apply {
|
.apply {
|
||||||
set(
|
set(
|
||||||
indexOf(contact),
|
indexOf(contact),
|
||||||
|
@ -105,111 +101,40 @@ class LocalAddressBookDataSourceImpl(
|
||||||
}
|
}
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
)
|
||||||
writeAddressBookToLocalStorage(contacts!!)
|
writeAddressBookToLocalStorage(addressBook!!)
|
||||||
contacts!!
|
addressBook!!
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteContact(addressBookContact: AddressBookContact): AddressBook =
|
override suspend fun deleteContact(addressBookContact: AddressBookContact): AddressBook =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val lastUpdated = Clock.System.now()
|
val lastUpdated = Clock.System.now()
|
||||||
contacts =
|
addressBook =
|
||||||
AddressBook(
|
AddressBook(
|
||||||
lastUpdated = lastUpdated,
|
lastUpdated = lastUpdated,
|
||||||
version = 1,
|
version = 1,
|
||||||
contacts =
|
contacts =
|
||||||
contacts?.contacts.orEmpty().toMutableList()
|
addressBook?.contacts.orEmpty().toMutableList()
|
||||||
.apply {
|
.apply {
|
||||||
remove(addressBookContact)
|
remove(addressBookContact)
|
||||||
}
|
}
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
)
|
||||||
writeAddressBookToLocalStorage(contacts!!)
|
writeAddressBookToLocalStorage(addressBook!!)
|
||||||
contacts!!
|
addressBook!!
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveContacts(contacts: AddressBook) {
|
override suspend fun saveContacts(contacts: AddressBook) {
|
||||||
writeAddressBookToLocalStorage(contacts)
|
writeAddressBookToLocalStorage(contacts)
|
||||||
this@LocalAddressBookDataSourceImpl.contacts = contacts
|
this@LocalAddressBookDataSourceImpl.addressBook = contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readLocalFileToAddressBook(): AddressBook? {
|
private fun readLocalFileToAddressBook(): AddressBook? {
|
||||||
return localAddressBookStorageProvider.openStorageInputStream()?.let {
|
val file = addressBookStorageProvider.getStorageFile() ?: return null
|
||||||
deserializeByteArrayFileToAddressBook(
|
return addressBookProvider.readAddressBookFromFile(file)
|
||||||
inputStream = it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeAddressBookToLocalStorage(addressBook: AddressBook) {
|
private fun writeAddressBookToLocalStorage(addressBook: AddressBook) {
|
||||||
localAddressBookStorageProvider.openStorageOutputStream()?.let {
|
val file = addressBookStorageProvider.getOrCreateStorageFile()
|
||||||
serializeAddressBookToByteArray(
|
addressBookProvider.writeAddressBookToFile(file, addressBook)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val BYTE_ORDER = ByteOrder.BIG_ENDIAN
|
|
||||||
|
|
|
@ -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"
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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"
|
|
|
@ -1,22 +1,52 @@
|
||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package co.electriccoin.zcash.ui.common.repository
|
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.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.AddressBook
|
||||||
import co.electriccoin.zcash.ui.common.model.AddressBookContact
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.onSubscription
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
interface AddressBookRepository {
|
interface AddressBookRepository {
|
||||||
val addressBook: Flow<AddressBook?>
|
val addressBook: Flow<AddressBook?>
|
||||||
|
|
||||||
|
val googleSignInRequest: Flow<Unit>
|
||||||
|
|
||||||
|
val googleRemoteConsentRequest: Flow<Intent>
|
||||||
|
|
||||||
suspend fun saveContact(
|
suspend fun saveContact(
|
||||||
name: String,
|
name: String,
|
||||||
address: String
|
address: String
|
||||||
|
@ -29,15 +59,28 @@ interface AddressBookRepository {
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun deleteContact(contact: AddressBookContact)
|
suspend fun deleteContact(contact: AddressBookContact)
|
||||||
|
|
||||||
|
fun onGoogleSignInSuccess()
|
||||||
|
|
||||||
|
fun onGoogleSignInCancelled(status: Status?)
|
||||||
|
|
||||||
|
fun onGoogleSignInError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
class AddressBookRepositoryImpl(
|
class AddressBookRepositoryImpl(
|
||||||
private val localAddressBookDataSource: LocalAddressBookDataSource,
|
private val localAddressBookDataSource: LocalAddressBookDataSource,
|
||||||
private val remoteAddressBookProvider: RemoteAddressBookProvider
|
private val remoteAddressBookDataSource: RemoteAddressBookDataSource,
|
||||||
|
private val context: Context
|
||||||
) : AddressBookRepository {
|
) : AddressBookRepository {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||||
|
|
||||||
private val semaphore = Mutex()
|
private val semaphore = Mutex()
|
||||||
|
|
||||||
private val addressBookCache = MutableStateFlow<AddressBook?>(null)
|
private val addressBookCache = MutableStateFlow<AddressBook?>(null)
|
||||||
|
|
||||||
|
private var internalOperation: InternalOperation? = null
|
||||||
|
|
||||||
override val addressBook: Flow<AddressBook?> =
|
override val addressBook: Flow<AddressBook?> =
|
||||||
addressBookCache
|
addressBookCache
|
||||||
.onSubscription {
|
.onSubscription {
|
||||||
|
@ -45,62 +88,232 @@ class AddressBookRepositoryImpl(
|
||||||
ensureSynchronization()
|
ensureSynchronization()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.stateIn(scope = scope, started = SharingStarted.WhileSubscribed(60.seconds), initialValue = null)
|
||||||
|
|
||||||
|
override val googleSignInRequest = MutableSharedFlow<Unit>()
|
||||||
|
|
||||||
|
override val googleRemoteConsentRequest = MutableSharedFlow<Intent>()
|
||||||
|
|
||||||
|
private val internalOperationCompleted = MutableSharedFlow<InternalOperation>()
|
||||||
|
|
||||||
override suspend fun saveContact(
|
override suspend fun saveContact(
|
||||||
name: String,
|
name: String,
|
||||||
address: String
|
address: String
|
||||||
) = withNonCancellableSemaphore {
|
) = withGoogleDrivePermission(InternalOperation.Save(name = name, address = address))
|
||||||
ensureSynchronization()
|
|
||||||
val local = localAddressBookDataSource.saveContact(name, address)
|
|
||||||
addressBookCache.update { local }
|
|
||||||
remoteAddressBookProvider.uploadContacts()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateContact(
|
override suspend fun updateContact(
|
||||||
contact: AddressBookContact,
|
contact: AddressBookContact,
|
||||||
name: String,
|
name: String,
|
||||||
address: String
|
address: String
|
||||||
) {
|
) = withGoogleDrivePermission(InternalOperation.Update(contact = contact, name = name, address = address))
|
||||||
withNonCancellableSemaphore {
|
|
||||||
ensureSynchronization()
|
override suspend fun deleteContact(contact: AddressBookContact) =
|
||||||
val local = localAddressBookDataSource.updateContact(contact, name, address)
|
withGoogleDrivePermission(
|
||||||
addressBookCache.update { local }
|
InternalOperation.Delete(contact = contact)
|
||||||
remoteAddressBookProvider.uploadContacts()
|
)
|
||||||
|
|
||||||
|
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) =
|
override fun onGoogleSignInCancelled(status: Status?) {
|
||||||
withNonCancellableSemaphore {
|
scope.launch {
|
||||||
ensureSynchronization()
|
withNonCancellableSemaphore {
|
||||||
val local = localAddressBookDataSource.deleteContact(contact)
|
Twig.info { "Google sign in cancelled, $status" }
|
||||||
addressBookCache.update { local }
|
internalOperation?.let {
|
||||||
remoteAddressBookProvider.uploadContacts()
|
executeInternalOperation(operation = it)
|
||||||
|
this@AddressBookRepositoryImpl.internalOperation = null
|
||||||
|
internalOperationCompleted.emit(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureSynchronization() {
|
override fun onGoogleSignInError() {
|
||||||
if (addressBookCache.value == null) {
|
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 =
|
val merged =
|
||||||
mergeContacts(
|
mergeContacts(
|
||||||
local = localAddressBookDataSource.getContacts(),
|
local = localAddressBookDataSource.getContacts(),
|
||||||
remote = remoteAddressBookProvider.fetchContacts(),
|
remote = remote,
|
||||||
|
fromOperation = operation
|
||||||
)
|
)
|
||||||
|
|
||||||
localAddressBookDataSource.saveContacts(merged)
|
localAddressBookDataSource.saveContacts(merged)
|
||||||
remoteAddressBookProvider.uploadContacts()
|
executeRemoteAddressBookSafe {
|
||||||
|
remoteAddressBookDataSource.uploadContacts()
|
||||||
|
Twig.info { "Address Book: ensureSynchronization - remote address book uploaded" }
|
||||||
|
}
|
||||||
addressBookCache.update { merged }
|
addressBookCache.update { merged }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
private fun mergeContacts(
|
private fun mergeContacts(
|
||||||
local: AddressBook,
|
local: AddressBook,
|
||||||
remote: AddressBook?
|
remote: AddressBook?,
|
||||||
): AddressBook = local // TBD
|
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) {
|
private suspend fun withNonCancellableSemaphore(block: suspend () -> Unit) {
|
||||||
withContext(NonCancellable + Dispatchers.Default) {
|
withContext(NonCancellable + Dispatchers.Default) {
|
||||||
semaphore.withLock { block() }
|
semaphore.withLock { block() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
private suspend fun <T> 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
|
||||||
|
|
|
@ -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.TransactionOverview
|
||||||
import cash.z.ecc.android.sdk.model.TransactionRecipient
|
import cash.z.ecc.android.sdk.model.TransactionRecipient
|
||||||
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
|
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.ext.TransactionOverviewExt
|
||||||
import co.electriccoin.zcash.ui.screen.account.model.TransactionUi
|
import co.electriccoin.zcash.ui.screen.account.model.TransactionUi
|
||||||
import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
import kotlinx.coroutines.flow.combine
|
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.stateIn
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class TransactionHistoryViewModel(
|
class TransactionHistoryViewModel(
|
||||||
private val getContactByAddress: GetContactByAddressUseCase
|
private val observeAddressBookContacts: ObserveAddressBookContactsUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val state: MutableStateFlow<State> = MutableStateFlow(State.LOADING)
|
private val state: MutableStateFlow<State> = MutableStateFlow(State.LOADING)
|
||||||
|
|
||||||
private val transactions: MutableStateFlow<ImmutableList<TransactionUi>> = MutableStateFlow(persistentListOf())
|
private val transactions: MutableStateFlow<ImmutableList<TransactionUi>> = MutableStateFlow(persistentListOf())
|
||||||
|
|
||||||
|
private var transactionSyncJob: Job? = null
|
||||||
|
|
||||||
val transactionUiState: StateFlow<TransactionUiState> =
|
val transactionUiState: StateFlow<TransactionUiState> =
|
||||||
state.combine(transactions) { state: State, transactions: ImmutableList<TransactionUi> ->
|
state.combine(transactions) { state: State, transactions: ImmutableList<TransactionUi> ->
|
||||||
when (state) {
|
when (state) {
|
||||||
|
@ -48,39 +56,73 @@ class TransactionHistoryViewModel(
|
||||||
TransactionUiState.Loading
|
TransactionUiState.Loading
|
||||||
)
|
)
|
||||||
|
|
||||||
fun processTransactionState(dataState: TransactionHistorySyncState) =
|
fun processTransactionState(dataState: TransactionHistorySyncState) {
|
||||||
viewModelScope.launch {
|
transactionSyncJob?.cancel()
|
||||||
when (dataState) {
|
transactionSyncJob =
|
||||||
TransactionHistorySyncState.Loading -> {
|
viewModelScope.launch {
|
||||||
state.value = State.LOADING
|
when (dataState) {
|
||||||
transactions.value = persistentListOf()
|
TransactionHistorySyncState.Loading -> {
|
||||||
}
|
state.value = State.LOADING
|
||||||
is TransactionHistorySyncState.Syncing -> {
|
transactions.value = persistentListOf()
|
||||||
if (dataState.transactions.isEmpty()) {
|
|
||||||
state.value = State.SYNCING_EMPTY
|
|
||||||
} else {
|
|
||||||
state.value = State.SYNCING
|
|
||||||
transactions.value =
|
|
||||||
dataState.transactions
|
|
||||||
.map { data -> getOrUpdateTransactionItem(data) }
|
|
||||||
.toPersistentList()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
is TransactionHistorySyncState.Done -> {
|
is TransactionHistorySyncState.Syncing -> {
|
||||||
if (dataState.transactions.isEmpty()) {
|
if (dataState.transactions.isEmpty()) {
|
||||||
state.value = State.DONE_EMPTY
|
state.value = State.SYNCING_EMPTY
|
||||||
} else {
|
} else {
|
||||||
state.value = State.DONE
|
state.value = State.SYNCING
|
||||||
transactions.value =
|
|
||||||
dataState.transactions
|
observeAddressBookContacts()
|
||||||
.map { data -> getOrUpdateTransactionItem(data) }
|
.map { contacts ->
|
||||||
.toPersistentList()
|
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 =
|
val existingTransaction =
|
||||||
transactions.value.find {
|
transactions.value.find {
|
||||||
data.overview.rawId == it.overview.rawId
|
data.overview.rawId == it.overview.rawId
|
||||||
|
@ -89,10 +131,7 @@ class TransactionHistoryViewModel(
|
||||||
data = data,
|
data = data,
|
||||||
expandableState = existingTransaction?.expandableState ?: TrxItemState.COLLAPSED,
|
expandableState = existingTransaction?.expandableState ?: TrxItemState.COLLAPSED,
|
||||||
messages = existingTransaction?.messages,
|
messages = existingTransaction?.messages,
|
||||||
addressBookContact =
|
addressBookContact = addressBookContact
|
||||||
(data.recipient as? TransactionRecipient.Address)?.addressValue?.let {
|
|
||||||
getContactByAddress(it)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ class AddressBookViewModel(
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
||||||
initialValue = createState(contacts = emptyList())
|
initialValue = createState(contacts = null)
|
||||||
)
|
)
|
||||||
|
|
||||||
val navigationCommand = MutableSharedFlow<String>()
|
val navigationCommand = MutableSharedFlow<String>()
|
||||||
|
|
|
@ -40,6 +40,7 @@ class AddContactViewModel(
|
||||||
when (validateContactAddress(address)) {
|
when (validateContactAddress(address)) {
|
||||||
ValidateContactAddressUseCase.Result.Invalid ->
|
ValidateContactAddressUseCase.Result.Invalid ->
|
||||||
stringRes(R.string.contact_address_error_invalid)
|
stringRes(R.string.contact_address_error_invalid)
|
||||||
|
|
||||||
ValidateContactAddressUseCase.Result.NotUnique ->
|
ValidateContactAddressUseCase.Result.NotUnique ->
|
||||||
stringRes(R.string.contact_address_error_not_unique)
|
stringRes(R.string.contact_address_error_not_unique)
|
||||||
|
|
||||||
|
@ -141,6 +142,8 @@ class AddContactViewModel(
|
||||||
|
|
||||||
private fun onSaveButtonClick() =
|
private fun onSaveButtonClick() =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
if (isSavingContact.value) return@launch
|
||||||
|
|
||||||
isSavingContact.update { true }
|
isSavingContact.update { true }
|
||||||
saveContact(name = contactName.value, address = contactAddress.value)
|
saveContact(name = contactName.value, address = contactAddress.value)
|
||||||
backNavigationCommand.emit(Unit)
|
backNavigationCommand.emit(Unit)
|
||||||
|
|
|
@ -173,6 +173,7 @@ class UpdateContactViewModel(
|
||||||
|
|
||||||
private fun onUpdateButtonClick() =
|
private fun onUpdateButtonClick() =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
if (isDeletingContact.value || isUpdatingContact.value) return@launch
|
||||||
contact?.let {
|
contact?.let {
|
||||||
isUpdatingContact.update { true }
|
isUpdatingContact.update { true }
|
||||||
updateContact(contact = it, name = contactName.value, address = contactAddress.value)
|
updateContact(contact = it, name = contactName.value, address = contactAddress.value)
|
||||||
|
@ -183,6 +184,7 @@ class UpdateContactViewModel(
|
||||||
|
|
||||||
private fun onDeleteButtonClick() =
|
private fun onDeleteButtonClick() =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
if (isDeletingContact.value || isUpdatingContact.value) return@launch
|
||||||
contact?.let {
|
contact?.let {
|
||||||
isDeletingContact.update { true }
|
isDeletingContact.update { true }
|
||||||
deleteContact(it)
|
deleteContact(it)
|
||||||
|
|
Loading…
Reference in New Issue