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
### Added
- Address book local and remote storage support
- New QR Code detail screen has been added
## [1.2 (739)] - 2024-09-27

View File

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

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
### Added
- Address book local and remote storage support
- New QR Code detail screen has been added
## [1.2 (739)] - 2024-09-27

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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