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:
Milan 2024-10-16 11:12:03 +02:00 committed by GitHub
parent 4d0c04f93b
commit 624bee88ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 803 additions and 242 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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 {
} }
} }
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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)
} }
/** /**

View File

@ -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

View File

@ -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"

View File

@ -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
}
}

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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)
}
) )
} }

View File

@ -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>()

View File

@ -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)

View File

@ -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)