[#1533] Flexa integration

* [#1533] Flexa integration

Closes #1533

* [#1533] Code cleanup

* [#1533] Finalisation

* [#1618] Flexa payment biometrics

* [#1618] Design update

* [#1618] Local maven added

* [#1618] Code cleanup

* [#1533] Material3 version bump

* Fix proguard rules

* [#1533] Flexa hotfixes

* [#1533] Flexa hotfixes

* Changelogs update

---------

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Milan 2024-11-04 12:53:43 +01:00 committed by GitHub
parent ba761c2f37
commit c773e7d1c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 949 additions and 51 deletions

View File

@ -157,6 +157,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEY_ALIAS_PASSWORD }}
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:publishToGooglePlay
- name: Collect Artifacts

View File

@ -307,6 +307,7 @@ jobs:
# Because Fulladle doesn't allow Test Orchestrator to be enabled/disabled for a specific submodule, it must be enabled for all modules
ORG_GRADLE_PROJECT_IS_USE_TEST_ORCHESTRATOR: true
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew runFlank
- name: Collect Artifacts
@ -355,6 +356,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_EMULATOR_WTF_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY }}
ORG_GRADLE_PROJECT_IS_ANDROID_INSTRUMENTATION_TEST_COVERAGE_ENABLED: true
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew testDebugWithEmulatorWtf :ui-integration-test:testZcashmainnetDebugWithEmulatorWtf
- name: Collect Artifacts
@ -403,6 +405,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_EMULATOR_WTF_API_KEY: ${{ secrets.EMULATOR_WTF_API_KEY }}
ORG_GRADLE_PROJECT_IS_ANDROID_INSTRUMENTATION_TEST_COVERAGE_ENABLED: false
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:testZcashmainnetDebugWithEmulatorWtf :ui-screenshot-test:testZcashmainnetDebugWithEmulatorWtf
- name: Collect Artifacts
@ -462,6 +465,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_SUPPORT_EMAIL_ADDRESS: ${{ vars.SUPPORT_EMAIL_ADDRESS }}
ORG_GRADLE_PROJECT_IS_CRASH_ON_STRICT_MODE_VIOLATION: true
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:assembleDebug
- name: Authenticate to Google Cloud for Firebase Test Lab
@ -533,6 +537,7 @@ jobs:
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS: androiddebugkey
ORG_GRADLE_PROJECT_ZCASH_RELEASE_KEY_ALIAS_PASSWORD: android
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
./gradlew :app:assembleDebug :app:bundleRelease :app:packageZcashmainnetReleaseUniversalApk
- name: Collect Artifacts
@ -598,6 +603,7 @@ jobs:
GOOGLE_CLOUD_PROJECT: ${{ vars.FIREBASE_TEST_LAB_PROJECT }}
ORG_GRADLE_PROJECT_ZCASH_FIREBASE_TEST_LAB_API_KEY_PATH: ${{ steps.auth_test_lab.outputs.credentials_file_path }}
ORG_GRADLE_PROJECT_ZCASH_COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }}
ORG_GRADLE_PROJECT_ZCASH_FLEXA_KEY: ${{ secrets.FLEXA_PUBLISHABLE_KEY }}
run: |
unzip ${BINARIES_ZIP_PATH}
./gradlew :app:runFlankSanityConfigRelease

View File

@ -8,6 +8,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
### Added
- The device authentication feature on the Zashi app launch has been added
- The Flexa SDK has been adopted to enable payments using the embedded Flexa UI
## [1.2.1 (760)] - 2024-10-22

View File

@ -10,12 +10,13 @@
-printconfiguration build/outputs/proguard-config.txt
# This is generated automatically by the Android Gradle plugin.
-dontwarn com.google.j2objc.annotations.ReflectionSupport
-dontwarn com.google.j2objc.annotations.RetainedWith
-dontwarn androidx.compose.ui.util.MathHelpersKt
-dontwarn com.google.common.util.concurrent.ListenableFuture
-dontwarn com.google.errorprone.annotations.InlineMe
-dontwarn com.google.errorprone.annotations.MustBeClosed
-dontwarn com.google.j2objc.annotations.ReflectionSupport
-dontwarn com.google.j2objc.annotations.ReflectionSupport$Level
-dontwarn com.google.j2objc.annotations.RetainedWith
-dontwarn javax.naming.directory.Attribute
-dontwarn javax.naming.directory.Attributes
-dontwarn javax.naming.directory.DirContext

View File

@ -10,6 +10,7 @@ import co.electriccoin.zcash.di.viewModelModule
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.StrictModeCompat
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
@ -20,6 +21,7 @@ import org.koin.core.context.startKoin
@Suppress("unused")
class ZcashApplication : CoroutineApplication() {
private val standardPreferenceProvider by inject<StandardPreferenceProvider>()
private val flexaRepository by inject<FlexaRepository>()
override fun onCreate() {
super.onCreate()
@ -44,6 +46,8 @@ class ZcashApplication : CoroutineApplication() {
// Since analytics will need disk IO internally, we want this to be registered after strict
// mode is configured to ensure none of that IO happens on the main thread
configureAnalytics()
flexaRepository.init()
}
private fun configureLogging() {

View File

@ -45,6 +45,7 @@ buildscript {
}
}
}
maven("${rootProject.projectDir}/maven") // url to a local maven in this repository
}
dependencies {
@ -121,6 +122,7 @@ tasks {
"ZCASH_GOOGLE_PLAY_DEPLOY_TRACK" to "internal",
"ZCASH_GOOGLE_PLAY_DEPLOY_STATUS" to "draft",
"ZCASH_FLEXA_KEY" to "",
"ZCASH_COINBASE_APP_ID" to "",
"SDK_INCLUDED_BUILD_PATH" to "",
"BIP_39_INCLUDED_BUILD_PATH" to ""

View File

@ -11,6 +11,7 @@ directly impact users rather than highlighting other key architectural updates.*
### Added
- The device authentication feature on the Zashi app launch has been added
- The Flexa SDK has been adopted to enable payments using the embedded Flexa UI
## [1.2.1 (760)] - 2024-10-22

View File

@ -88,6 +88,9 @@ IS_SCREEN_ROTATION_ENABLED=false
# set it up.
ZCASH_COINBASE_APP_ID=
# Set the flexa publishable key to setup local integration. Replaced by CI action.
ZCASH_FLEXA_KEY=
# Set keystore details to enable build signing. Typically these
# are overridden via ~/.gradle/gradle.properties to allow secure injection.
# Debug keystore is useful if using Google Maps or Firebase, which require API keys to be linked
@ -167,7 +170,7 @@ ANDROIDX_ANNOTATION_VERSION=1.7.1
ANDROIDX_BIOMETRIC_VERSION=1.2.0-alpha05
ANDROIDX_CAMERA_VERSION=1.3.2
ANDROIDX_COMPOSE_COMPILER_VERSION=1.5.11
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.2.1
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.3.1
ANDROIDX_COMPOSE_MATERIAL_ICONS_VERSION=1.6.5
ANDROIDX_COMPOSE_VERSION=1.6.6
ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.1
@ -189,7 +192,7 @@ ANDROIDX_TEST_SERVICE_VERSION=1.4.2
ANDROIDX_UI_AUTOMATOR_VERSION=2.3.0
ANDROIDX_WORK_MANAGER_VERSION=2.9.0
ANDROIDX_BROWSER_VERSION=1.8.0
CORE_LIBRARY_DESUGARING_VERSION=2.0.4
CORE_LIBRARY_DESUGARING_VERSION=2.1.2
FIREBASE_BOM_VERSION_MATCHER=33.1.1
GOOGLE_API_CLIENT_ANDROID_VERSION=1.26.0
GOOGLE_API_SERVICES_DRIVE_VERSION=v3-rev136-1.25.0
@ -213,6 +216,8 @@ ZXING_VERSION=3.5.3
ZIP_321_VERSION = 0.0.6
ZCASH_BIP39_VERSION=1.0.8
FLEXA_VERSION=1.0.5
# WARNING: Ensure a non-snapshot version is used before releasing to production
ZCASH_SDK_VERSION=2.2.5

Binary file not shown.

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>com.flexa</groupId>
<artifactId>core</artifactId>
<version>1.0.5</version>
<packaging>aar</packaging>
<dependencies>
<dependency>
<groupId>androidx.core</groupId>
<artifactId>core-ktx</artifactId>
<version>1.13.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.ui</groupId>
<artifactId>ui</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.ui</groupId>
<artifactId>ui-tooling-preview</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.material</groupId>
<artifactId>material-icons-extended</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.material3</groupId>
<artifactId>material3</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>androidx.activity</groupId>
<artifactId>activity-compose</artifactId>
<version>1.9.2</version>
</dependency>
<dependency>
<groupId>androidx.compose.runtime</groupId>
<artifactId>runtime-livedata</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.navigation</groupId>
<artifactId>navigation-compose</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>androidx.webkit</groupId>
<artifactId>webkit</artifactId>
<version>1.12.1</version>
</dependency>
<dependency>
<groupId>androidx.lifecycle</groupId>
<artifactId>lifecycle-runtime-compose</artifactId>
<version>2.8.5</version>
</dependency>
<dependency>
<groupId>androidx.lifecycle</groupId>
<artifactId>lifecycle-viewmodel-compose</artifactId>
<version>2.8.5</version>
</dependency>
<dependency>
<groupId>androidx.appcompat</groupId>
<artifactId>appcompat</artifactId>
<version>1.7.0</version>
</dependency>
<dependency>
<groupId>io.coil-kt</groupId>
<artifactId>coil-compose</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.12.7</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>logging-interceptor</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>androidx.security</groupId>
<artifactId>security-crypto</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink-android</artifactId>
<version>1.8.0</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>1.6.3</version>
</dependency>
<dependency>
<groupId>androidx.room</groupId>
<artifactId>room-runtime</artifactId>
<version>2.6.1</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.flexa</groupId>
<artifactId>core</artifactId>
<versioning>
<latest>1.0.5</latest>
<release>1.0.5</release>
<versions>
<version>1.0.5</version>
</versions>
<lastUpdated>20241030092850</lastUpdated>
</versioning>
</metadata>

Binary file not shown.

View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>com.flexa</groupId>
<artifactId>spend</artifactId>
<version>1.0.5</version>
<packaging>aar</packaging>
<dependencies>
<dependency>
<groupId>com.flexa</groupId>
<artifactId>core</artifactId>
<version>1.0.5</version>
</dependency>
<dependency>
<groupId>androidx.core</groupId>
<artifactId>core-ktx</artifactId>
<version>1.13.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.ui</groupId>
<artifactId>ui</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.ui</groupId>
<artifactId>ui-util</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.ui</groupId>
<artifactId>ui-tooling-preview</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.material</groupId>
<artifactId>material-icons-extended</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.compose.material3</groupId>
<artifactId>material3</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>androidx.activity</groupId>
<artifactId>activity-compose</artifactId>
<version>1.9.2</version>
</dependency>
<dependency>
<groupId>androidx.lifecycle</groupId>
<artifactId>lifecycle-viewmodel-compose</artifactId>
<version>2.8.5</version>
</dependency>
<dependency>
<groupId>androidx.navigation</groupId>
<artifactId>navigation-compose</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>androidx.compose.runtime</groupId>
<artifactId>runtime-livedata</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>androidx.webkit</groupId>
<artifactId>webkit</artifactId>
<version>1.12.1</version>
</dependency>
<dependency>
<groupId>io.coil-kt</groupId>
<artifactId>coil-compose</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.coil-kt</groupId>
<artifactId>coil-svg</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>com.caverock</groupId>
<artifactId>androidsvg-aar</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<dependency>
<groupId>androidx.work</groupId>
<artifactId>work-runtime-ktx</artifactId>
<version>2.9.1</version>
</dependency>
<dependency>
<groupId>com.flexa</groupId>
<artifactId>core</artifactId>
<version>1.0.5</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.flexa</groupId>
<artifactId>spend</artifactId>
<versioning>
<latest>1.0.5</latest>
<release>1.0.5</release>
<versions>
<version>1.0.5</version>
</versions>
<lastUpdated>20241030092854</lastUpdated>
</versioning>
</metadata>

View File

@ -135,6 +135,7 @@ dependencyResolutionManagement {
}
}
}
maven("${rootProject.projectDir}/maven") // url to a local maven in this repository
}
@Suppress("MaxLineLength")
@ -190,6 +191,7 @@ dependencyResolutionManagement {
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()
val flexaVersion = extra["FLEXA_VERSION"].toString()
// Standalone versions
@ -259,6 +261,8 @@ dependencyResolutionManagement {
library("zxing", "com.google.zxing:core:$zxingVersion")
library("koin", "io.insert-koin:koin-android:$koinVersion")
library("koin-compose", "io.insert-koin:koin-androidx-compose:$koinVersion")
library("flexa-core", "com.flexa:core:$flexaVersion")
library("flexa-spend", "com.flexa:spend:$flexaVersion")
// Test libraries
library("androidx-compose-test-junit", "androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
@ -109,8 +110,9 @@ fun AppAlertDialog(
text = text,
icon = icon?.let { { Icon(imageVector = icon, null) } },
properties = properties,
titleContentColor = ZcashTheme.colors.textPrimary,
textContentColor = ZcashTheme.colors.textPrimary,
containerColor = ZashiColors.Surfaces.bgPrimary,
titleContentColor = ZashiColors.Text.textPrimary,
textContentColor = ZashiColors.Text.textPrimary,
modifier = modifier,
)
}

View File

@ -92,10 +92,12 @@ fun SmallLinearProgressIndicator(
modifier: Modifier = Modifier
) {
LinearProgressIndicator(
drawStopIndicator = {},
progress = { progress },
color = ZcashTheme.colors.linearProgressBarBackground,
trackColor = ZcashTheme.colors.linearProgressBarTrack,
strokeCap = StrokeCap.Butt,
gapSize = 0.dp,
modifier =
Modifier
.fillMaxWidth()

View File

@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -56,7 +56,7 @@ fun RadioButton(
modifier
.clip(RoundedCornerShape(12.dp))
.clickable(
indication = rememberRipple(),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = state.onClick,
role = Role.Button,

View File

@ -13,9 +13,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -145,7 +146,7 @@ fun ZashiSettingsListContentItem(
titleIcons.forEach {
Spacer(Modifier.width(6.dp))
Image(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(20.dp).clip(CircleShape),
painter = painterResource(it),
contentDescription = null,
)
@ -176,7 +177,7 @@ fun ZashiSettingsListItem(
.clip(RoundedCornerShape(12.dp)) then
if (onClick != null) {
Modifier.clickable(
indication = rememberRipple(),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
role = Role.Button,

View File

@ -1,7 +1,12 @@
package co.electriccoin.zcash.ui.design.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.RippleDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import co.electriccoin.zcash.ui.design.theme.colors.DarkZashiColorsInternal
@ -26,6 +31,7 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypographyInternal
* @param forceDarkMode Set this to true to force the app to use the dark mode theme, which is helpful, e.g.,
* for the compose previews.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZcashTheme(
forceDarkMode: Boolean = false,
@ -39,7 +45,8 @@ fun ZcashTheme(
CompositionLocalProvider(
LocalExtendedColors provides extendedColors,
LocalZashiColors provides zashiColors,
LocalZashiTypography provides ZashiTypographyInternal
LocalZashiTypography provides ZashiTypographyInternal,
LocalRippleConfiguration provides MaterialRippleConfig,
) {
ProvideDimens {
MaterialTheme(
@ -71,3 +78,8 @@ object ZcashTheme {
@Composable
get() = localDimens.current
}
@OptIn(ExperimentalMaterial3Api::class)
private val MaterialRippleConfig: RippleConfiguration
@Composable
get() = RippleConfiguration(color = LocalContentColor.current, rippleAlpha = RippleDefaults.RippleAlpha)

View File

@ -82,6 +82,14 @@ androidComponents {
comment = "Whether is the SecureScreen sensitive data protection enabled"
)
)
variant.buildConfigFields.put(
"ZCASH_FLEXA_KEY",
BuildConfigField(
type = "String",
value = "\"${project.property("ZCASH_FLEXA_KEY")?.toString().orEmpty()}\"",
comment = "Publishable key of the Flexa integration"
)
)
variant.buildConfigFields.put(
"ZCASH_COINBASE_APP_ID",
BuildConfigField(
@ -129,6 +137,9 @@ dependencies {
implementation(libs.zcash.bip39)
implementation(libs.zxing)
api(libs.flexa.core)
api(libs.flexa.spend)
implementation(projects.buildInfoLib)
implementation(projects.configurationApiLib)
implementation(projects.crashAndroidLib)

View File

@ -22,6 +22,11 @@
android:label="@string/app_name"
android:theme="@style/Theme.App.Starting" />
<activity
android:name=".BiometricActivity"
android:exported="false"
android:theme="@style/Theme.App.Transparent" />
</application>
</manifest>

View File

@ -4,10 +4,14 @@ import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.BalanceRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.common.repository.FlexaRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepositoryImpl
import org.koin.core.module.dsl.singleOf
@ -21,4 +25,6 @@ val repositoryModule =
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::class
singleOf(::FlexaRepositoryImpl) bind FlexaRepository::class
singleOf(::BiometricRepositoryImpl) bind BiometricRepository::class
}

View File

@ -11,6 +11,7 @@ import co.electriccoin.zcash.ui.common.usecase.GetSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase
@ -60,6 +61,7 @@ val useCaseModule =
singleOf(::ObserveContactPickedUseCase)
singleOf(::GetAddressesUseCase)
singleOf(::CopyToClipboardUseCase)
singleOf(::IsFlexaAvailableUseCase)
singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase)
singleOf(::Zip321ProposalFromUriUseCase)

View File

@ -79,4 +79,5 @@ val viewModelModule =
zip321ParseUriValidationUseCase = get(),
)
}
viewModelOf(::IntegrationsViewModel)
}

View File

@ -0,0 +1,70 @@
package co.electriccoin.zcash.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricResult
import org.koin.android.ext.android.inject
class BiometricActivity : FragmentActivity() {
private val biometricRepository by inject<BiometricRepository>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requestCode = intent.getStringExtra(EXTRA_REQUEST_CODE).orEmpty()
val subtitle = intent.getStringExtra(EXTRA_SUBTITLE).orEmpty()
val biometricPrompt =
BiometricPrompt(
this,
ContextCompat.getMainExecutor(application),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
biometricRepository.onBiometricResult(BiometricResult.Failure(requestCode))
finish()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
biometricRepository.onBiometricResult(BiometricResult.Success(requestCode))
finish()
}
}
)
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setTitle(
getString(R.string.authentication_system_ui_title, getString(R.string.app_name))
)
.setSubtitle(subtitle)
.setAllowedAuthenticators(biometricRepository.allowedAuthenticators)
.build()
biometricPrompt.authenticate(promptInfo)
}
companion object {
private const val EXTRA_REQUEST_CODE = "EXTRA_REQUEST_CODE"
private const val EXTRA_SUBTITLE = "EXTRA_SUBTITLE"
fun createIntent(
context: Context,
requestCode: String,
subtitle: String
) = Intent(context, BiometricActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(EXTRA_REQUEST_CODE, requestCode)
putExtra(EXTRA_SUBTITLE, subtitle)
}
}
}

View File

@ -73,6 +73,7 @@ private fun BalanceWidgetNotAvailableYetPreview() {
balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(value = 0L),
spendableBalance = Zatoshi(value = 0L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
isHideBalances = false,
@ -96,6 +97,7 @@ private fun BalanceWidgetHiddenAmountPreview() {
balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(0L),
spendableBalance = Zatoshi(0L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
isHideBalances = true,
@ -109,23 +111,26 @@ private fun BalanceWidgetHiddenAmountPreview() {
sealed interface BalanceState {
val totalBalance: Zatoshi
val spendableBalance: Zatoshi
val exchangeRate: ExchangeRateState
data class None(
override val exchangeRate: ExchangeRateState
) : BalanceState {
override val totalBalance: Zatoshi = Zatoshi(0L)
override val spendableBalance: Zatoshi = Zatoshi(0L)
}
data class Loading(
override val totalBalance: Zatoshi,
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState
) : BalanceState
data class Available(
override val totalBalance: Zatoshi,
override val exchangeRate: ExchangeRateState,
val spendableBalance: Zatoshi
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState
) : BalanceState
}

View File

@ -48,6 +48,7 @@ class BalanceRepositoryImpl(
) -> {
BalanceState.Loading(
totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance(),
exchangeRate = exchangeRateUsd
)
}

View File

@ -0,0 +1,108 @@
package co.electriccoin.zcash.ui.common.repository
import android.content.Context
import androidx.biometric.BiometricManager
import co.electriccoin.zcash.spackle.AndroidApiVersion
import co.electriccoin.zcash.ui.BiometricActivity
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.UUID
interface BiometricRepository {
val allowedAuthenticators: Int
fun onBiometricResult(result: BiometricResult)
@Throws(BiometricsFailureException::class, BiometricsCancelledException::class)
suspend fun requestBiometrics(request: BiometricRequest)
}
data class BiometricRequest(
val message: StringResource,
val requestCode: String = UUID.randomUUID().toString(),
)
sealed interface BiometricResult {
val requestCode: String
data class Success(override val requestCode: String) : BiometricResult
data class Failure(override val requestCode: String) : BiometricResult
data class Cancelled(override val requestCode: String) : BiometricResult
}
class BiometricsFailureException : Exception()
class BiometricsCancelledException : Exception()
class BiometricRepositoryImpl(
private val context: Context,
private val biometricManager: BiometricManager
) : BiometricRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val onBiometricsResult = MutableSharedFlow<BiometricResult>()
override val allowedAuthenticators: Int
get() =
when {
// Android SDK version == 27
(AndroidApiVersion.isExactlyO) ->
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
// Android SDK version >= 30
(AndroidApiVersion.isAtLeastR) ->
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
// Android SDK version == 28 || 29
(AndroidApiVersion.isExactlyP || AndroidApiVersion.isExactlyQ) ->
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
else -> error("Unsupported Android SDK version")
}
override fun onBiometricResult(result: BiometricResult) {
scope.launch {
onBiometricsResult.emit(result)
}
}
override suspend fun requestBiometrics(request: BiometricRequest) {
if (!canAuthenticate()) {
// do nothing
return
}
context.startActivity(
BiometricActivity.createIntent(
context = context,
requestCode = request.requestCode,
subtitle = request.message.getString(context)
)
)
when (
onBiometricsResult.filter { it.requestCode == request.requestCode }.first()
) {
is BiometricResult.Cancelled -> throw BiometricsCancelledException()
is BiometricResult.Failure -> throw BiometricsFailureException()
is BiometricResult.Success -> {
// do nothing
}
}
}
private fun canAuthenticate(): Boolean =
when (biometricManager.canAuthenticate(allowedAuthenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}

View File

@ -0,0 +1,117 @@
package co.electriccoin.zcash.ui.common.repository
import android.app.Application
import cash.z.ecc.android.sdk.ext.convertZatoshiToZec
import cash.z.ecc.android.sdk.internal.Twig
import co.electriccoin.zcash.ui.BuildConfig
import com.flexa.core.Flexa
import com.flexa.core.shared.AssetAccount
import com.flexa.core.shared.AvailableAsset
import com.flexa.core.shared.CustodyModel
import com.flexa.core.shared.FlexaClientConfiguration
import com.flexa.core.theme.FlexaTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.UUID
interface FlexaRepository {
fun init()
}
class FlexaRepositoryImpl(
private val balanceRepository: BalanceRepository,
private val application: Application,
) : FlexaRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val publishableKey: String?
get() = BuildConfig.ZCASH_FLEXA_KEY.takeIf { it.isNotEmpty() }
override fun init() {
scope.launch {
val configuration = getFlexaClientConfiguration()
if (configuration != null) {
Flexa.init(configuration)
Twig.info { "Flexa initialized" }
balanceRepository.state
.map { it.spendableBalance }
.collect {
Flexa.updateAssetAccounts(
arrayListOf(
createFlexaAccount(
zecBalance = it.convertZatoshiToZec().toDouble()
)
)
)
Twig.info { "Flexa updated by ${it.convertZatoshiToZec().toDouble()}" }
}
}
}
}
/**
* @return an instance of [FlexaClientConfiguration] or null if no publishable key set up
*/
private fun getFlexaClientConfiguration() =
publishableKey?.let { publishableKey ->
FlexaClientConfiguration(
context = application,
publishableKey = publishableKey,
theme =
FlexaTheme(
useDynamicColorScheme = true,
),
assetAccounts = arrayListOf(createFlexaAccount(DEFAULT_ZEC_BALANCE)),
webViewThemeConfig =
"{\n" +
" \"android\": {\n" +
" \"light\": {\n" +
" \"backgroundColor\": \"#100e29\",\n" +
" \"sortTextColor\": \"#ed7f60\",\n" +
" \"titleColor\": \"#ffffff\",\n" +
" \"cardColor\": \"#2a254e\",\n" +
" \"borderRadius\": \"15px\",\n" +
" \"textColor\": \"#ffffff\"\n" +
" },\n" +
" \"dark\": {\n" +
" \"backgroundColor\": \"#100e29\",\n" +
" \"sortTextColor\": \"#ed7f60\",\n" +
" \"titleColor\": \"#ffffff\",\n" +
" \"cardColor\": \"#2a254e\",\n" +
" \"borderRadius\": \"15px\",\n" +
" \"textColor\": \"#ffffff\"\n" +
" }\n" +
" }\n" +
"}"
)
}
private fun createFlexaAccount(zecBalance: Double) =
AssetAccount(
displayName = "",
icon = "https://flexa.network/static/4bbb1733b3ef41240ca0f0675502c4f7/d8419/flexa-logo%403x.png",
availableAssets =
listOf(
AvailableAsset(
assetId = "bip122:00040fe8ec8471911baa1db1266ea15d/slip44:133",
balance = zecBalance,
symbol = "ZEC",
)
),
custodyModel = CustodyModel.LOCAL,
assetAccountHash = UUID.randomUUID().toString().toSha256()
)
private fun String.toSha256() =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray())
.fold("") { str, value -> str + "%02x".format(value) }
}
private const val DEFAULT_ZEC_BALANCE = .0

View File

@ -0,0 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
class IsFlexaAvailableUseCase(
private val getVersionInfo: GetVersionInfoProvider,
) {
operator fun invoke(): Boolean {
val versionInfo = getVersionInfo()
val isDebug = versionInfo.let { it.isDebuggable && !it.isRunningUnderTestService }
return !versionInfo.isTestnet && (BuildConfig.ZCASH_FLEXA_KEY.isNotEmpty() || isDebug)
}
}

View File

@ -29,11 +29,15 @@ import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.usecase.DeleteAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt
import co.electriccoin.zcash.ui.screen.account.ext.getSortHeight
import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState
import com.flexa.core.Flexa
import com.flexa.identity.buildIdentity
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@ -45,8 +49,11 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@ -63,6 +70,7 @@ class WalletViewModel(
private val standardPreferenceProvider: StandardPreferenceProvider,
private val getAvailableServers: GetDefaultServersProvider,
private val deleteAddressBookUseCase: DeleteAddressBookUseCase,
private val isFlexaAvailable: IsFlexaAvailableUseCase
) : AndroidViewModel(application) {
val navigationCommand = exchangeRateRepository.navigationCommand
@ -234,29 +242,27 @@ class WalletViewModel(
fun deleteWalletFlow(activity: Activity): Flow<Boolean> =
callbackFlow {
Twig.info { "Delete wallet: Requested" }
disconnectFlexa()
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
(synchronizer as SdkSynchronizer).closeFlow().collect {
Twig.info { "Delete wallet: SDK closed" }
(synchronizer as SdkSynchronizer).closeFlow().collect {
Twig.info { "Delete wallet: SDK closed" }
walletCoordinator.deleteSdkDataFlow().collect { isSdkErased ->
Twig.info { "Delete wallet: Erase SDK result: $isSdkErased" }
if (!isSdkErased) {
walletCoordinator.deleteSdkDataFlow().collect { isSdkErased ->
Twig.info { "Delete wallet: Erase SDK result: $isSdkErased" }
if (!isSdkErased) {
trySend(false)
}
clearAppStateFlow().collect { isAppErased ->
Twig.info { "Delete wallet: Erase App result: $isAppErased" }
if (!isAppErased) {
trySend(false)
}
clearAppStateFlow().collect { isAppErased ->
Twig.info { "Delete wallet: Erase App result: $isAppErased" }
if (!isAppErased) {
trySend(false)
} else {
trySend(true)
activity.run {
finish()
startActivity(Intent(this, MainActivity::class.java))
}
} else {
trySend(true)
activity.run {
finish()
startActivity(Intent(this, MainActivity::class.java))
}
}
}
@ -266,6 +272,16 @@ class WalletViewModel(
awaitClose {
// Nothing to close
}
}.flowOn(Dispatchers.Main)
private suspend fun disconnectFlexa() =
suspendCoroutine { cont ->
if (isFlexaAvailable()) {
Flexa.buildIdentity().build().disconnect()
cont.resume(Unit)
} else {
cont.resume(Unit)
}
}
}

View File

@ -18,11 +18,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -628,7 +628,7 @@ private fun HistoryItemExpandedAddressPart(
Modifier
.clickable(
role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
indication = ripple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.AddressClick(recipient)) }
)
@ -646,7 +646,7 @@ private fun HistoryItemExpandedAddressPart(
.weight(1f)
.clickable(
role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
indication = ripple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.AddToAddressBookClick(recipient)) }
)
@ -781,7 +781,7 @@ private fun HistoryItemTransactionIdPart(
Modifier
.clickable(
role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
indication = ripple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.TransactionIdClick(txIdString)) }
)
@ -944,7 +944,7 @@ private fun HistoryItemMessagePart(
.clickable(
onClick = { onAction(TrxItemAction.MessageClick(message)) },
role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
indication = ripple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
)
)

View File

@ -253,7 +253,7 @@ private fun ExchangeRateButton(
if (isEnabled && enableBorder) {
ZashiColors.Surfaces.bgPrimary orDark ZashiColors.Surfaces.bgTertiary
} else {
Color.Unspecified
Color.Transparent
},
disabledContainerColor = Color.Transparent,
disabledContentColor = textColor,

View File

@ -112,7 +112,6 @@ private fun HomeContent(
key = { index ->
subScreens[index].title
},
beyondBoundsPageCount = 1,
modifier =
Modifier.constrainAs(pager) {
top.linkTo(parent.top)

View File

@ -13,6 +13,8 @@ import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.integrations.view.Integrations
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import com.flexa.core.Flexa
import com.flexa.spend.buildSpend
import org.koin.androidx.compose.koinViewModel
@Composable
@ -42,6 +44,17 @@ internal fun WrapIntegrations() {
}
}
LaunchedEffect(Unit) {
viewModel.flexaNavigationCommand.collect {
Flexa.buildSpend()
.onTransactionRequest {
viewModel.onFlexaResultCallback(it)
}
.build()
.open(activity)
}
}
BackHandler {
viewModel.onBack()
}

View File

@ -8,5 +8,5 @@ data class IntegrationsState(
val version: StringResource,
val disabledInfo: StringResource?,
val onBack: () -> Unit,
val items: ImmutableList<ZashiSettingsListItemState>
val items: ImmutableList<ZashiSettingsListItemState>,
)

View File

@ -1,19 +1,41 @@
package co.electriccoin.zcash.ui.screen.integrations.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRequest
import co.electriccoin.zcash.ui.common.usecase.GetSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SubmitResult
import com.flexa.core.Flexa
import com.flexa.spend.Transaction
import com.flexa.spend.buildSpend
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
@ -26,10 +48,16 @@ class IntegrationsViewModel(
getVersionInfo: GetVersionInfoProvider,
getZcashCurrency: GetZcashCurrencyProvider,
observeWalletState: ObserveWalletStateUseCase,
private val getSynchronizer: GetSynchronizerUseCase,
private val getTransparentAddress: GetTransparentAddressUseCase,
private val isFlexaAvailable: IsFlexaAvailableUseCase,
private val isCoinbaseAvailable: IsCoinbaseAvailableUseCase,
private val getSpendingKey: GetSpendingKeyUseCase,
private val context: Context,
private val biometricRepository: BiometricRepository
) : ViewModel() {
val backNavigationCommand = MutableSharedFlow<Unit>()
val flexaNavigationCommand = MutableSharedFlow<Unit>()
val coinbaseNavigationCommand = MutableSharedFlow<String>()
private val versionInfo = getVersionInfo()
@ -60,7 +88,21 @@ class IntegrationsViewModel(
getZcashCurrency.getLocalizedName()
),
onClick = ::onBuyWithCoinbaseClicked
).takeIf { isCoinbaseAvailable() }
).takeIf { isCoinbaseAvailable() },
ZashiSettingsListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from
// the UI in the Testnet build
isEnabled = isEnabled,
icon =
if (isEnabled) {
R.drawable.ic_integrations_flexa
} else {
R.drawable.ic_integrations_flexa_disabled
},
text = stringRes(R.string.integrations_flexa),
subtitle = stringRes(R.string.integrations_flexa_subtitle),
onClick = ::onFlexaClicked
).takeIf { isFlexaAvailable() }
).toImmutableList()
)
}.stateIn(
@ -98,4 +140,168 @@ class IntegrationsViewModel(
}
}
}
private fun onFlexaClicked() =
viewModelScope.launch {
flexaNavigationCommand.emit(Unit)
}
fun onFlexaResultCallback(transaction: Result<Transaction>) =
viewModelScope.launch {
runCatching {
biometricRepository.requestBiometrics(
BiometricRequest(message = stringRes(R.string.integrations_biometric_message))
)
Twig.debug { "Getting send transaction proposal" }
getSynchronizer()
.proposeSend(
account = getSpendingKey().account,
send = getZecSend(transaction.getOrNull())
)
}.onSuccess { proposal ->
Twig.debug { "Transaction proposal successful: ${proposal.toPrettyString()}" }
val result = submitTransactions(proposal = proposal, spendingKey = getSpendingKey())
when (result.first) {
SubmitResult.Success -> {
Twig.debug { "Transaction successful $result" }
Flexa.buildSpend()
.transactionSent(
commerceSessionId = transaction.getOrNull()?.commerceSessionId.orEmpty(),
txSignature = result.second.orEmpty()
)
}
else -> {
Twig.error { "Transaction submission failed" }
}
}
}.onFailure {
Twig.error(it) { "Transaction proposal failed" }
}
}
private suspend fun submitTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
): Pair<SubmitResult, String?> {
Twig.debug { "Sending transactions..." }
val result =
runCreateTransactions(
synchronizer = getSynchronizer(),
spendingKey = spendingKey,
proposal = proposal
)
// Triggering the transaction history and balances refresh to be notified immediately
// about the wallet's updated state
(getSynchronizer() as SdkSynchronizer).run {
refreshTransactions()
refreshAllBalances()
}
return result
}
private suspend fun runCreateTransactions(
synchronizer: Synchronizer,
spendingKey: UnifiedSpendingKey,
proposal: Proposal
): Pair<SubmitResult, String?> {
val submitResults = mutableListOf<TransactionSubmitResult>()
return runCatching {
synchronizer.createProposedTransactions(
proposal = proposal,
usk = spendingKey
).collect { submitResult ->
Twig.info { "Transaction submit result: $submitResult" }
submitResults.add(submitResult)
}
if (submitResults.find { it is TransactionSubmitResult.Failure } != null) {
if (submitResults.size == 1) {
// The first transaction submission failed - user might just be able to re-submit the transaction
// proposal. Simple error pop up is fine then
val result = (submitResults[0] as TransactionSubmitResult.Failure)
if (result.grpcError) {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc(result) to null
} else {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureSubmit(result) to null
}
} else {
// Any subsequent transaction submission failed - user needs to resolve this manually. Multiple
// transaction failure screen presented
SubmitResult.MultipleTrxFailure to null
}
} else {
// All transaction submissions were successful
SubmitResult.Success to
submitResults.filterIsInstance<TransactionSubmitResult.Success>()
.map { it.txIdString() }.firstOrNull()
}
}.onSuccess {
Twig.debug { "Transactions submitted successfully" }
}.onFailure {
Twig.error(it) { "Transactions submission failed" }
}.getOrElse {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureOther(it) to null
}
}
@Suppress("TooGenericExceptionThrown")
private suspend fun getZecSend(transaction: Transaction?): ZecSend {
if (transaction == null) throw NullPointerException("Transaction is null")
val address = transaction.destinationAddress.split(":").last()
val recipientAddressState =
RecipientAddressState.new(
address = address,
// TODO [#342]: Verify Addresses without Synchronizer
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
type = getSynchronizer().validateAddress(address)
)
return when (
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = address,
zecString = transaction.amount,
// Take memo for a valid non-transparent receiver only
memoString = ""
)
) {
is ZecSendExt.ZecSendValidation.Valid ->
zecSendValidation.zecSend.copy(
destination =
when (recipientAddressState.type) {
is AddressType.Invalid ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Shielded ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Tex ->
WalletAddress.Tex.new(recipientAddressState.address)
AddressType.Transparent ->
WalletAddress.Transparent.new(recipientAddressState.address)
AddressType.Unified ->
WalletAddress.Unified.new(recipientAddressState.address)
null -> WalletAddress.Unified.new(recipientAddressState.address)
}
)
is ZecSendExt.ZecSendValidation.Invalid -> {
// We do not expect this validation to fail, so logging is enough here
// An error popup could be reasonable here as well
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" }
throw RuntimeException("Validation failed")
}
}
}
}

View File

@ -596,7 +596,7 @@ private fun SeedGridWithText(
keyboardOptions =
KeyboardOptions(
KeyboardCapitalization.None,
autoCorrect = false,
autoCorrectEnabled = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password
),
@ -809,7 +809,7 @@ private fun RestoreBirthdayMainContent(
keyboardOptions =
KeyboardOptions(
KeyboardCapitalization.None,
autoCorrect = false,
autoCorrectEnabled = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number
),

View File

@ -62,7 +62,6 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -74,6 +73,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState

View File

@ -23,9 +23,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -609,7 +609,7 @@ fun SendFormAddressTextField(
Modifier.clickable(
onClick = sendAddressBookState.onButtonClick,
role = Role.Button,
indication = rememberRipple(radius = 4.dp),
indication = ripple(radius = 4.dp),
interactionSource = remember { MutableInteractionSource() }
),
painter = painterResource(sendAddressBookState.mode.icon),
@ -623,7 +623,7 @@ fun SendFormAddressTextField(
Modifier.clickable(
onClick = onQrScannerOpen,
role = Role.Button,
indication = rememberRipple(radius = 4.dp),
indication = ripple(radius = 4.dp),
interactionSource = remember { MutableInteractionSource() }
),
painter = painterResource(R.drawable.qr_code_icon),

View File

@ -11,6 +11,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
@ -22,6 +23,7 @@ import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingItemState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -38,7 +40,8 @@ class SettingsViewModel(
observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider,
private val getVersionInfo: GetVersionInfoProvider,
private val rescanBlockchain: RescanBlockchainUseCase
private val rescanBlockchain: RescanBlockchainUseCase,
private val isFlexaAvailable: IsFlexaAvailableUseCase
) : ViewModel() {
private val versionInfo by lazy { getVersionInfo() }
@ -112,7 +115,11 @@ class SettingsViewModel(
text = stringRes(R.string.settings_integrations),
icon = R.drawable.ic_settings_integrations,
onClick = ::onIntegrationsClick,
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
titleIcons =
listOfNotNull(
R.drawable.ic_integrations_coinbase,
R.drawable.ic_integrations_flexa.takeIf { isFlexaAvailable() }
).toImmutableList()
),
ZashiSettingsListItemState(
text = stringRes(R.string.settings_advanced_settings),

View File

@ -5,4 +5,9 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/no_icon_splash_logo</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.NoActionBar</item>
</style>
<style name="Theme.App.Transparent" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -2,6 +2,9 @@
<string name="integrations_title">Integrations</string>
<string name="integrations_coinbase">Buy <xliff:g id="currency" example="ZEC">%1$s</xliff:g> with Coinbase</string>
<string name="integrations_coinbase_subtitle">A hassle-free way to buy <xliff:g id="currency" example="ZEC">%1$s</xliff:g> and get it directly into your Zashi wallet.</string>
<string name="integrations_version">Version %s</string>
<string name="integrations_flexa">Pay with Flexa</string>
<string name="integrations_flexa_subtitle">Pay with Flexa payment clips and explore a new way of spending Zcash.</string>
<string name="integrations_version">Version <xliff:g id="version" example="1.2.1">%1$s</xliff:g></string>
<string name="integrations_disabled_info">During the Restore process, it is not possible to use payment integrations.</string>
<string name="integrations_biometric_message">Authenticate yourself to pay with Flexa</string>
</resources>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,20C0,8.954 8.954,0 20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20Z"
android:fillColor="#454243"/>
<path
android:pathData="M24.167,26.667H24C22.6,26.667 21.9,26.667 21.365,26.394C20.895,26.154 20.512,25.772 20.272,25.302C20,24.767 20,24.067 20,22.667V17.333C20,15.933 20,15.233 20.272,14.698C20.512,14.228 20.895,13.845 21.365,13.606C21.9,13.333 22.6,13.333 24,13.333H24.167M24.167,26.667C24.167,27.587 24.913,28.333 25.833,28.333C26.754,28.333 27.5,27.587 27.5,26.667C27.5,25.746 26.754,25 25.833,25C24.913,25 24.167,25.746 24.167,26.667ZM24.167,13.333C24.167,14.254 24.913,15 25.833,15C26.754,15 27.5,14.254 27.5,13.333C27.5,12.413 26.754,11.667 25.833,11.667C24.913,11.667 24.167,12.413 24.167,13.333ZM15.833,20L24.167,20M15.833,20C15.833,20.92 15.087,21.667 14.167,21.667C13.246,21.667 12.5,20.92 12.5,20C12.5,19.079 13.246,18.333 14.167,18.333C15.087,18.333 15.833,19.079 15.833,20ZM24.167,20C24.167,20.92 24.913,21.667 25.833,21.667C26.754,21.667 27.5,20.92 27.5,20C27.5,19.079 26.754,18.333 25.833,18.333C24.913,18.333 24.167,19.079 24.167,20Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#E8E8E8"
android:strokeLineCap="round"/>
</vector>