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
|
||||
|
||||
### Added
|
||||
- Address book local and remote storage support
|
||||
- New QR Code detail screen has been added
|
||||
|
||||
## [1.2 (739)] - 2024-09-27
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<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?) {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
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<AddressBook?>
|
||||
|
||||
val googleSignInRequest: Flow<Unit>
|
||||
|
||||
val googleRemoteConsentRequest: Flow<Intent>
|
||||
|
||||
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<AddressBook?>(null)
|
||||
|
||||
private var internalOperation: InternalOperation? = null
|
||||
|
||||
override val addressBook: Flow<AddressBook?> =
|
||||
addressBookCache
|
||||
.onSubscription {
|
||||
|
@ -45,62 +88,232 @@ class AddressBookRepositoryImpl(
|
|||
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(
|
||||
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 <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.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<State> = MutableStateFlow(State.LOADING)
|
||||
|
||||
private val transactions: MutableStateFlow<ImmutableList<TransactionUi>> = MutableStateFlow(persistentListOf())
|
||||
|
||||
private var transactionSyncJob: Job? = null
|
||||
|
||||
val transactionUiState: StateFlow<TransactionUiState> =
|
||||
state.combine(transactions) { state: State, transactions: ImmutableList<TransactionUi> ->
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String>()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue