[#1417] Add in-app authentication
* [#1417] Add authentication - Closes #1417 - Closes #326 - Partially addresses [Electric-Coin-Company/zashi#7] too - Creates reusable AuthenticationVM component with all necessary logic that reports authentication status to its callers - Addresses authentication requirements for the Send funds, Delete wallet, Export private data, and Recovery phrase. The App access authentication use case is prepared and can be turned on anytime. - The new logic also counts with possible future user customization via the app UI of the default on/off states for all implemented authentication use cases - Send.Confirmation logic simplification - This also adds the welcome screen (splash) animation to all the app entry points (the app recreation caused by system included) * Allow unauthenticated access - In case no authentication method is available on the device * Build supported authenticators for the device - Based on the device Android SDK version * Disable broken screenshot testing - This is a temporary change until #1448 is addressed * Changelog update * Add temporary placeholder screenshot test To suppress no test error
This commit is contained in:
parent
02e67ae778
commit
00db536674
|
@ -11,6 +11,7 @@ syntax: glob
|
||||||
.idea/workspace.xml
|
.idea/workspace.xml
|
||||||
.idea/deploymentTargetSelector.xml
|
.idea/deploymentTargetSelector.xml
|
||||||
.idea/migrations.xml
|
.idea/migrations.xml
|
||||||
|
.idea/studiobot.xml
|
||||||
.settings
|
.settings
|
||||||
*.iml
|
*.iml
|
||||||
bin/
|
bin/
|
||||||
|
|
|
@ -9,6 +9,12 @@ directly impact users rather than highlighting other key architectural updates.*
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Zashi now provides system biometric or device credential (pattern, pin, or password) authentication for these use
|
||||||
|
cases: Send funds, Recovery Phrase, Export Private Data, and Delete Wallet.
|
||||||
|
- The app entry animation has been reworked to apply on every app access point, i.e. it will be displayed when
|
||||||
|
users return to an already set up app as well.
|
||||||
|
|
||||||
## [1.0 (650)] - 2024-05-07
|
## [1.0 (650)] - 2024-05-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -157,6 +157,7 @@ ACCOMPANIST_PERMISSIONS_VERSION=0.34.0
|
||||||
ANDROIDX_ACTIVITY_VERSION=1.8.2
|
ANDROIDX_ACTIVITY_VERSION=1.8.2
|
||||||
ANDROIDX_ANNOTATION_VERSION=1.7.1
|
ANDROIDX_ANNOTATION_VERSION=1.7.1
|
||||||
ANDROIDX_APPCOMPAT_VERSION=1.6.1
|
ANDROIDX_APPCOMPAT_VERSION=1.6.1
|
||||||
|
ANDROIDX_BIOMETRIC_VERSION=1.2.0-alpha05
|
||||||
ANDROIDX_CAMERA_VERSION=1.3.2
|
ANDROIDX_CAMERA_VERSION=1.3.2
|
||||||
ANDROIDX_COMPOSE_COMPILER_VERSION=1.5.11
|
ANDROIDX_COMPOSE_COMPILER_VERSION=1.5.11
|
||||||
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.2.1
|
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.2.1
|
||||||
|
|
|
@ -118,7 +118,6 @@ class AndroidPreferenceProvider(
|
||||||
|
|
||||||
val mainKey =
|
val mainKey =
|
||||||
withContext(singleThreadedDispatcher) {
|
withContext(singleThreadedDispatcher) {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
MasterKey.Builder(context).apply {
|
MasterKey.Builder(context).apply {
|
||||||
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
}.build()
|
}.build()
|
||||||
|
@ -126,7 +125,6 @@ class AndroidPreferenceProvider(
|
||||||
|
|
||||||
val sharedPreferences =
|
val sharedPreferences =
|
||||||
withContext(singleThreadedDispatcher) {
|
withContext(singleThreadedDispatcher) {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
EncryptedSharedPreferences.create(
|
EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
filename,
|
filename,
|
||||||
|
|
|
@ -144,6 +144,7 @@ dependencyResolutionManagement {
|
||||||
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
|
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
|
||||||
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
||||||
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
|
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
|
||||||
|
val androidxBiometricVersion = extra["ANDROIDX_BIOMETRIC_VERSION"].toString()
|
||||||
val androidxCameraVersion = extra["ANDROIDX_CAMERA_VERSION"].toString()
|
val androidxCameraVersion = extra["ANDROIDX_CAMERA_VERSION"].toString()
|
||||||
val androidxComposeCompilerVersion = extra["ANDROIDX_COMPOSE_COMPILER_VERSION"].toString()
|
val androidxComposeCompilerVersion = extra["ANDROIDX_COMPOSE_COMPILER_VERSION"].toString()
|
||||||
val androidxComposeMaterial3Version = extra["ANDROIDX_COMPOSE_MATERIAL3_VERSION"].toString()
|
val androidxComposeMaterial3Version = extra["ANDROIDX_COMPOSE_MATERIAL3_VERSION"].toString()
|
||||||
|
@ -192,6 +193,8 @@ dependencyResolutionManagement {
|
||||||
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
|
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
|
||||||
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
|
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
|
||||||
library("androidx-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion")
|
library("androidx-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion")
|
||||||
|
library("androidx-biometric", "androidx.biometric:biometric:$androidxBiometricVersion")
|
||||||
|
library("androidx-biometric-ktx", "androidx.biometric:biometric-ktx:$androidxBiometricVersion")
|
||||||
library("androidx-camera", "androidx.camera:camera-camera2:$androidxCameraVersion")
|
library("androidx-camera", "androidx.camera:camera-camera2:$androidxCameraVersion")
|
||||||
library("androidx-camera-lifecycle", "androidx.camera:camera-lifecycle:$androidxCameraVersion")
|
library("androidx-camera-lifecycle", "androidx.camera:camera-lifecycle:$androidxCameraVersion")
|
||||||
library("androidx-camera-view", "androidx.camera:camera-view:$androidxCameraVersion")
|
library("androidx-camera-view", "androidx.camera:camera-view:$androidxCameraVersion")
|
||||||
|
@ -251,6 +254,13 @@ dependencyResolutionManagement {
|
||||||
library("androidx-uiAutomator", "androidx.test.uiautomator:uiautomator:$androidxUiAutomatorVersion")
|
library("androidx-uiAutomator", "androidx.test.uiautomator:uiautomator:$androidxUiAutomatorVersion")
|
||||||
library("kotlinx-coroutines-test", "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion")
|
library("kotlinx-coroutines-test", "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion")
|
||||||
// Bundles
|
// Bundles
|
||||||
|
bundle(
|
||||||
|
"androidx-biometric",
|
||||||
|
listOf(
|
||||||
|
"androidx-biometric",
|
||||||
|
"androidx-biometric-ktx",
|
||||||
|
)
|
||||||
|
)
|
||||||
bundle(
|
bundle(
|
||||||
"androidx-camera",
|
"androidx-camera",
|
||||||
listOf(
|
listOf(
|
||||||
|
|
|
@ -11,12 +11,28 @@ object AndroidApiVersion {
|
||||||
* [sdk].
|
* [sdk].
|
||||||
*/
|
*/
|
||||||
@ChecksSdkIntAtLeast(parameter = 0)
|
@ChecksSdkIntAtLeast(parameter = 0)
|
||||||
fun isAtLeast(
|
private fun isAtLeast(
|
||||||
@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int
|
@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return Build.VERSION.SDK_INT >= sdk
|
return Build.VERSION.SDK_INT >= sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param sdk SDK version number to test against the current environment.
|
||||||
|
* @return `true` if [android.os.Build.VERSION.SDK_INT] is equal to [sdk].
|
||||||
|
*/
|
||||||
|
private fun isExactly(
|
||||||
|
@IntRange(from = Build.VERSION_CODES.BASE.toLong()) sdk: Int
|
||||||
|
): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT == sdk
|
||||||
|
}
|
||||||
|
|
||||||
|
val isExactlyO = isExactly(Build.VERSION_CODES.O_MR1)
|
||||||
|
|
||||||
|
val isExactlyP = isExactly(Build.VERSION_CODES.P)
|
||||||
|
|
||||||
|
val isExactlyQ = isExactly(Build.VERSION_CODES.Q)
|
||||||
|
|
||||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
|
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
|
||||||
val isAtLeastP = isAtLeast(Build.VERSION_CODES.P)
|
val isAtLeastP = isAtLeast(Build.VERSION_CODES.P)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
@file:Suppress("MatchingDeclarationName")
|
||||||
|
|
||||||
|
package co.electriccoin.zcash.ui.design.component
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import co.electriccoin.zcash.ui.design.R
|
||||||
|
import co.electriccoin.zcash.ui.design.component.AnimationConstants.ANIMATION_DURATION
|
||||||
|
import co.electriccoin.zcash.ui.design.component.AnimationConstants.INITIAL_DELAY
|
||||||
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
import co.electriccoin.zcash.ui.design.util.screenHeight
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
object AnimationConstants {
|
||||||
|
const val ANIMATION_DURATION = 700
|
||||||
|
const val INITIAL_DELAY = 1000
|
||||||
|
|
||||||
|
fun together() = (ANIMATION_DURATION + INITIAL_DELAY).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO [#1002]: Welcome screen animation masking
|
||||||
|
// TODO [#1002]: https://github.com/Electric-Coin-Company/zashi-android/issues/1002
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WelcomeAnimationAutostart(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
delay: Duration = INITIAL_DELAY.milliseconds,
|
||||||
|
) {
|
||||||
|
var currentAnimationState by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
WelcomeAnimation(
|
||||||
|
animationState = currentAnimationState,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
|
||||||
|
// Let's start the animation automatically in case e.g. authentication is not involved
|
||||||
|
LaunchedEffect(key1 = currentAnimationState) {
|
||||||
|
delay(delay)
|
||||||
|
currentAnimationState = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LOGO_RELATIVE_LOCATION = 0.2f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WelcomeAnimation(
|
||||||
|
animationState: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val screenHeight = screenHeight()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier.then(
|
||||||
|
Modifier
|
||||||
|
.verticalScroll(
|
||||||
|
state = rememberScrollState(),
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
.wrapContentSize()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = animationState,
|
||||||
|
exit =
|
||||||
|
slideOutVertically(
|
||||||
|
targetOffsetY = { -it },
|
||||||
|
animationSpec =
|
||||||
|
tween(
|
||||||
|
durationMillis = ANIMATION_DURATION,
|
||||||
|
easing = FastOutLinearInEasing
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.wrapContentSize()) {
|
||||||
|
Column(modifier = Modifier.wrapContentSize()) {
|
||||||
|
Image(
|
||||||
|
painter = ColorPainter(ZcashTheme.colors.welcomeAnimationColor),
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.height(screenHeight.overallScreenHeight()),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.chart_line),
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.height(screenHeight.overallScreenHeight()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.fillMaxHeight(LOGO_RELATIVE_LOCATION))
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.logo_with_hi),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,6 +33,7 @@ android {
|
||||||
"src/main/res/ui/about",
|
"src/main/res/ui/about",
|
||||||
"src/main/res/ui/advanced_settings",
|
"src/main/res/ui/advanced_settings",
|
||||||
"src/main/res/ui/account",
|
"src/main/res/ui/account",
|
||||||
|
"src/main/res/ui/authentication",
|
||||||
"src/main/res/ui/balances",
|
"src/main/res/ui/balances",
|
||||||
"src/main/res/ui/common",
|
"src/main/res/ui/common",
|
||||||
"src/main/res/ui/delete_wallet",
|
"src/main/res/ui/delete_wallet",
|
||||||
|
@ -92,6 +93,7 @@ dependencies {
|
||||||
implementation(libs.androidx.lifecycle.livedata)
|
implementation(libs.androidx.lifecycle.livedata)
|
||||||
implementation(libs.androidx.splash)
|
implementation(libs.androidx.splash)
|
||||||
implementation(libs.androidx.workmanager)
|
implementation(libs.androidx.workmanager)
|
||||||
|
implementation(libs.bundles.androidx.biometric)
|
||||||
implementation(libs.bundles.androidx.camera)
|
implementation(libs.bundles.androidx.camera)
|
||||||
implementation(libs.bundles.androidx.compose.core)
|
implementation(libs.bundles.androidx.compose.core)
|
||||||
implementation(libs.bundles.androidx.compose.extended)
|
implementation(libs.bundles.androidx.compose.extended)
|
||||||
|
|
|
@ -3,7 +3,7 @@ package co.electriccoin.zcash.ui.screen.onboarding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class OnboardingTestSetup(
|
class OnboardingTestSetup(
|
||||||
|
@ -26,9 +26,7 @@ class OnboardingTestSetup(
|
||||||
@Suppress("TestFunctionName")
|
@Suppress("TestFunctionName")
|
||||||
fun DefaultContent() {
|
fun DefaultContent() {
|
||||||
ZcashTheme {
|
ZcashTheme {
|
||||||
ShortOnboarding(
|
Onboarding(
|
||||||
// It's fine to test the screen UI after the welcome animation
|
|
||||||
showWelcomeAnim = false,
|
|
||||||
// Debug only UI state does not need to be tested
|
// Debug only UI state does not need to be tested
|
||||||
isDebugMenuEnabled = false,
|
isDebugMenuEnabled = false,
|
||||||
onImportWallet = { onImportWalletCallbackCount.incrementAndGet() },
|
onImportWallet = { onImportWalletCallbackCount.incrementAndGet() },
|
||||||
|
|
|
@ -17,7 +17,7 @@ import org.junit.Rule
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class SeedRecoveryViewTest : UiTestPrerequisites() {
|
class SeedRecoveryRecoveryViewTest : UiTestPrerequisites() {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createComposeRule()
|
val composeTestRule = createComposeRule()
|
||||||
|
|
|
@ -16,7 +16,7 @@ import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class SeedRecoveryViewsSecuredScreenTest : UiTestPrerequisites() {
|
class SeedRecoveryRecoveryViewsSecuredScreenTest : UiTestPrerequisites() {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createComposeRule()
|
val composeTestRule = createComposeRule()
|
||||||
|
|
|
@ -4,15 +4,16 @@ import android.annotation.SuppressLint
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
@ -26,24 +27,33 @@ import cash.z.ecc.android.sdk.model.SeedPhrase
|
||||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||||
import cash.z.ecc.sdk.type.fromResources
|
import cash.z.ecc.sdk.type.fromResources
|
||||||
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
||||||
|
import co.electriccoin.zcash.spackle.Twig
|
||||||
import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider
|
import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider
|
||||||
import co.electriccoin.zcash.ui.common.model.OnboardingState
|
import co.electriccoin.zcash.ui.common.model.OnboardingState
|
||||||
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
|
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
|
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
||||||
|
import co.electriccoin.zcash.ui.design.component.AnimationConstants
|
||||||
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
|
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
|
||||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
import co.electriccoin.zcash.ui.design.component.Override
|
import co.electriccoin.zcash.ui.design.component.Override
|
||||||
|
import co.electriccoin.zcash.ui.design.component.WelcomeAnimationAutostart
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
|
||||||
import co.electriccoin.zcash.ui.screen.newwalletrecovery.WrapNewWalletRecovery
|
import co.electriccoin.zcash.ui.screen.newwalletrecovery.WrapNewWalletRecovery
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
|
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
|
||||||
import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning
|
import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning
|
||||||
|
import co.electriccoin.zcash.ui.screen.support.WrapSupport
|
||||||
import co.electriccoin.zcash.ui.screen.warning.WrapNotEnoughSpace
|
import co.electriccoin.zcash.ui.screen.warning.WrapNotEnoughSpace
|
||||||
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
|
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
|
||||||
import co.electriccoin.zcash.work.WorkIds
|
import co.electriccoin.zcash.work.WorkIds
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
@ -53,7 +63,7 @@ import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private val homeViewModel by viewModels<HomeViewModel>()
|
private val homeViewModel by viewModels<HomeViewModel>()
|
||||||
|
|
||||||
val walletViewModel by viewModels<WalletViewModel>()
|
val walletViewModel by viewModels<WalletViewModel>()
|
||||||
|
@ -61,6 +71,10 @@ class MainActivity : ComponentActivity() {
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
val storageCheckViewModel by viewModels<StorageCheckViewModel>()
|
val storageCheckViewModel by viewModels<StorageCheckViewModel>()
|
||||||
|
|
||||||
|
internal val authenticationViewModel by viewModels<AuthenticationViewModel> {
|
||||||
|
AuthenticationViewModel.AuthenticationViewModelFactory(application)
|
||||||
|
}
|
||||||
|
|
||||||
lateinit var navControllerForTesting: NavHostController
|
lateinit var navControllerForTesting: NavHostController
|
||||||
|
|
||||||
val configurationOverrideFlow = MutableStateFlow<ConfigurationOverride?>(null)
|
val configurationOverrideFlow = MutableStateFlow<ConfigurationOverride?>(null)
|
||||||
|
@ -130,6 +144,8 @@ class MainActivity : ComponentActivity() {
|
||||||
} else {
|
} else {
|
||||||
MainContent()
|
MainContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthenticationForAppAccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -141,6 +157,67 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthenticationForAppAccess() {
|
||||||
|
val authState = authenticationViewModel.appAccessAuthenticationResultState.collectAsStateWithLifecycle().value
|
||||||
|
val animateAppAccess = authenticationViewModel.showWelcomeAnimation.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
|
when (authState) {
|
||||||
|
AuthenticationUIState.Initial -> {
|
||||||
|
Twig.debug { "Authentication initial state" }
|
||||||
|
// Wait for the state update
|
||||||
|
}
|
||||||
|
AuthenticationUIState.NotRequired -> {
|
||||||
|
Twig.debug { "App access authentication NOT required - welcome animation only" }
|
||||||
|
if (animateAppAccess) {
|
||||||
|
WelcomeAnimationAutostart(
|
||||||
|
delay = AnimationConstants.INITIAL_DELAY.milliseconds
|
||||||
|
)
|
||||||
|
// Wait until the welcome animation finishes then mark it was shown
|
||||||
|
LaunchedEffect(key1 = authenticationViewModel.showWelcomeAnimation) {
|
||||||
|
delay(AnimationConstants.together())
|
||||||
|
authenticationViewModel.setWelcomeAnimationDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthenticationUIState.Required -> {
|
||||||
|
Twig.debug { "App access authentication required" }
|
||||||
|
|
||||||
|
// Check and trigger app access authentication if required
|
||||||
|
// Note that the Welcome animation is part of its logic
|
||||||
|
WrapAuthentication(
|
||||||
|
goSupport = {
|
||||||
|
authenticationViewModel.appAccessAuthentication.value = AuthenticationUIState.SupportedRequired
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// Wait until the welcome animation finishes, then mark it as presented to the user
|
||||||
|
delay((AnimationConstants.together()).milliseconds)
|
||||||
|
authenticationViewModel.appAccessAuthentication.value = AuthenticationUIState.Successful
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onFailed = {
|
||||||
|
// No subsequent action required. User is prompted with an explanation dialog.
|
||||||
|
},
|
||||||
|
useCase = AuthenticationUseCase.AppAccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUIState.SupportedRequired -> {
|
||||||
|
Twig.debug { "Authentication support required" }
|
||||||
|
WrapSupport(
|
||||||
|
goBack = { finish() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUIState.Successful -> {
|
||||||
|
Twig.debug { "Authentication successful - entering the app" }
|
||||||
|
// No action is needed - the main app content is laid out now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainContent() {
|
private fun MainContent() {
|
||||||
val configuration = homeViewModel.configurationFlow.collectAsStateWithLifecycle().value
|
val configuration = homeViewModel.configurationFlow.collectAsStateWithLifecycle().value
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
package co.electriccoin.zcash.ui
|
package co.electriccoin.zcash.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavOptionsBuilder
|
import androidx.navigation.NavOptionsBuilder
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
|
@ -35,6 +39,8 @@ import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransit
|
||||||
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
|
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
|
||||||
import co.electriccoin.zcash.ui.screen.about.WrapAbout
|
import co.electriccoin.zcash.ui.screen.about.WrapAbout
|
||||||
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
|
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
|
||||||
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
|
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
|
||||||
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet
|
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet
|
||||||
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
|
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
|
||||||
|
@ -49,6 +55,9 @@ import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationSt
|
||||||
import co.electriccoin.zcash.ui.screen.settings.WrapSettings
|
import co.electriccoin.zcash.ui.screen.settings.WrapSettings
|
||||||
import co.electriccoin.zcash.ui.screen.support.WrapSupport
|
import co.electriccoin.zcash.ui.screen.support.WrapSupport
|
||||||
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
|
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
// TODO [#1297]: Consider: Navigation passing complex data arguments different way
|
// TODO [#1297]: Consider: Navigation passing complex data arguments different way
|
||||||
|
@ -62,6 +71,14 @@ internal fun MainActivity.Navigation() {
|
||||||
navControllerForTesting = it
|
navControllerForTesting = it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper properties for triggering the system security UI from callbacks
|
||||||
|
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
|
||||||
|
rememberSaveable { mutableStateOf(false) }
|
||||||
|
val (seedRecoveryAuthentication, setSeedRecoveryAuthentication) =
|
||||||
|
rememberSaveable { mutableStateOf(false) }
|
||||||
|
val (deleteWalletAuthentication, setDeleteWalletAuthentication) =
|
||||||
|
rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = HOME,
|
startDestination = HOME,
|
||||||
|
@ -130,18 +147,60 @@ internal fun MainActivity.Navigation() {
|
||||||
navController.popBackStackJustOnce(ADVANCED_SETTINGS)
|
navController.popBackStackJustOnce(ADVANCED_SETTINGS)
|
||||||
},
|
},
|
||||||
goExportPrivateData = {
|
goExportPrivateData = {
|
||||||
navController.navigateJustOnce(EXPORT_PRIVATE_DATA)
|
navController.checkProtectedDestination(
|
||||||
|
scope = lifecycleScope,
|
||||||
|
propertyToCheck = authenticationViewModel.isExportPrivateDataAuthenticationRequired,
|
||||||
|
setCheckedProperty = setExportPrivateDataAuthentication,
|
||||||
|
unProtectedDestination = EXPORT_PRIVATE_DATA
|
||||||
|
)
|
||||||
},
|
},
|
||||||
goSeedRecovery = {
|
goSeedRecovery = {
|
||||||
navController.navigateJustOnce(SEED_RECOVERY)
|
navController.checkProtectedDestination(
|
||||||
|
scope = lifecycleScope,
|
||||||
|
propertyToCheck = authenticationViewModel.isSeedAuthenticationRequired,
|
||||||
|
setCheckedProperty = setSeedRecoveryAuthentication,
|
||||||
|
unProtectedDestination = SEED_RECOVERY
|
||||||
|
)
|
||||||
},
|
},
|
||||||
goChooseServer = {
|
goChooseServer = {
|
||||||
navController.navigateJustOnce(CHOOSE_SERVER)
|
navController.navigateJustOnce(CHOOSE_SERVER)
|
||||||
},
|
},
|
||||||
goDeleteWallet = {
|
goDeleteWallet = {
|
||||||
navController.navigateJustOnce(DELETE_WALLET)
|
navController.checkProtectedDestination(
|
||||||
|
scope = lifecycleScope,
|
||||||
|
propertyToCheck = authenticationViewModel.isDeleteWalletAuthenticationRequired,
|
||||||
|
setCheckedProperty = setDeleteWalletAuthentication,
|
||||||
|
unProtectedDestination = DELETE_WALLET
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
deleteWalletAuthentication -> {
|
||||||
|
ShowSystemAuthentication(
|
||||||
|
navHostController = navController,
|
||||||
|
protectedDestination = DELETE_WALLET,
|
||||||
|
protectedUseCase = AuthenticationUseCase.DeleteWallet,
|
||||||
|
setCheckedProperty = setDeleteWalletAuthentication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exportPrivateDataAuthentication -> {
|
||||||
|
ShowSystemAuthentication(
|
||||||
|
navHostController = navController,
|
||||||
|
protectedDestination = EXPORT_PRIVATE_DATA,
|
||||||
|
protectedUseCase = AuthenticationUseCase.ExportPrivateData,
|
||||||
|
setCheckedProperty = setExportPrivateDataAuthentication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seedRecoveryAuthentication -> {
|
||||||
|
ShowSystemAuthentication(
|
||||||
|
navHostController = navController,
|
||||||
|
protectedDestination = SEED_RECOVERY,
|
||||||
|
protectedUseCase = AuthenticationUseCase.SeedRecovery,
|
||||||
|
setCheckedProperty = setSeedRecoveryAuthentication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(CHOOSE_SERVER) {
|
composable(CHOOSE_SERVER) {
|
||||||
WrapChooseServer(
|
WrapChooseServer(
|
||||||
|
@ -153,9 +212,11 @@ internal fun MainActivity.Navigation() {
|
||||||
composable(SEED_RECOVERY) {
|
composable(SEED_RECOVERY) {
|
||||||
WrapSeedRecovery(
|
WrapSeedRecovery(
|
||||||
goBack = {
|
goBack = {
|
||||||
|
setSeedRecoveryAuthentication(false)
|
||||||
navController.popBackStackJustOnce(SEED_RECOVERY)
|
navController.popBackStackJustOnce(SEED_RECOVERY)
|
||||||
},
|
},
|
||||||
onDone = {
|
onDone = {
|
||||||
|
setSeedRecoveryAuthentication(false)
|
||||||
navController.popBackStackJustOnce(SEED_RECOVERY)
|
navController.popBackStackJustOnce(SEED_RECOVERY)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -165,7 +226,12 @@ internal fun MainActivity.Navigation() {
|
||||||
WrapSupport(goBack = { navController.popBackStackJustOnce(SUPPORT) })
|
WrapSupport(goBack = { navController.popBackStackJustOnce(SUPPORT) })
|
||||||
}
|
}
|
||||||
composable(DELETE_WALLET) {
|
composable(DELETE_WALLET) {
|
||||||
WrapDeleteWallet(goBack = { navController.popBackStackJustOnce(DELETE_WALLET) })
|
WrapDeleteWallet(
|
||||||
|
goBack = {
|
||||||
|
setDeleteWalletAuthentication(false)
|
||||||
|
navController.popBackStackJustOnce(DELETE_WALLET)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(ABOUT) {
|
composable(ABOUT) {
|
||||||
WrapAbout(goBack = { navController.popBackStackJustOnce(ABOUT) })
|
WrapAbout(goBack = { navController.popBackStackJustOnce(ABOUT) })
|
||||||
|
@ -186,8 +252,14 @@ internal fun MainActivity.Navigation() {
|
||||||
}
|
}
|
||||||
composable(EXPORT_PRIVATE_DATA) {
|
composable(EXPORT_PRIVATE_DATA) {
|
||||||
WrapExportPrivateData(
|
WrapExportPrivateData(
|
||||||
goBack = { navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA) },
|
goBack = {
|
||||||
onConfirm = { navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA) }
|
setExportPrivateDataAuthentication(false)
|
||||||
|
navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA)
|
||||||
|
},
|
||||||
|
onConfirm = {
|
||||||
|
setExportPrivateDataAuthentication(false)
|
||||||
|
navController.popBackStackJustOnce(EXPORT_PRIVATE_DATA)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(route = SEND_CONFIRMATION) {
|
composable(route = SEND_CONFIRMATION) {
|
||||||
|
@ -200,6 +272,7 @@ internal fun MainActivity.Navigation() {
|
||||||
navController.popBackStackJustOnce(SEND_CONFIRMATION)
|
navController.popBackStackJustOnce(SEND_CONFIRMATION)
|
||||||
},
|
},
|
||||||
goHome = { navController.navigateJustOnce(HOME) },
|
goHome = { navController.navigateJustOnce(HOME) },
|
||||||
|
goSupport = { navController.navigateJustOnce(SUPPORT) },
|
||||||
arguments = SendConfirmationArguments.fromSavedStateHandle(backStackEntry.savedStateHandle)
|
arguments = SendConfirmationArguments.fromSavedStateHandle(backStackEntry.savedStateHandle)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -207,6 +280,53 @@ internal fun MainActivity.Navigation() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainActivity.ShowSystemAuthentication(
|
||||||
|
navHostController: NavHostController,
|
||||||
|
protectedDestination: String,
|
||||||
|
protectedUseCase: AuthenticationUseCase,
|
||||||
|
setCheckedProperty: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
WrapAuthentication(
|
||||||
|
goSupport = {
|
||||||
|
setCheckedProperty(false)
|
||||||
|
navHostController.navigateJustOnce(SUPPORT)
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
navHostController.navigateJustOnce(protectedDestination)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
setCheckedProperty(false)
|
||||||
|
},
|
||||||
|
onFailed = {
|
||||||
|
setCheckedProperty(false)
|
||||||
|
},
|
||||||
|
useCase = protectedUseCase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and trigger authentication if required, navigate to the destination otherwise
|
||||||
|
*/
|
||||||
|
private fun NavHostController.checkProtectedDestination(
|
||||||
|
scope: LifecycleCoroutineScope,
|
||||||
|
propertyToCheck: StateFlow<Boolean?>,
|
||||||
|
setCheckedProperty: (Boolean) -> Unit,
|
||||||
|
unProtectedDestination: String
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
propertyToCheck
|
||||||
|
.filterNotNull()
|
||||||
|
.collect { isProtected ->
|
||||||
|
if (isProtected) {
|
||||||
|
setCheckedProperty(true)
|
||||||
|
} else {
|
||||||
|
navigateJustOnce(unProtectedDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun fillInHandleForConfirmation(
|
private fun fillInHandleForConfirmation(
|
||||||
handle: SavedStateHandle,
|
handle: SavedStateHandle,
|
||||||
zecSend: ZecSend?,
|
zecSend: ZecSend?,
|
||||||
|
|
|
@ -0,0 +1,415 @@
|
||||||
|
package co.electriccoin.zcash.ui.common.viewmodel
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricManager.Authenticators
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
|
||||||
|
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||||
|
import co.electriccoin.zcash.spackle.Twig
|
||||||
|
import co.electriccoin.zcash.ui.MainActivity
|
||||||
|
import co.electriccoin.zcash.ui.R
|
||||||
|
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
|
||||||
|
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
||||||
|
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
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.emitAll
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val DEFAULT_INITIAL_DELAY = 0
|
||||||
|
|
||||||
|
class AuthenticationViewModel(
|
||||||
|
private val application: Application,
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
private val executor: Executor by lazy { ContextCompat.getMainExecutor(application) }
|
||||||
|
private lateinit var biometricPrompt: BiometricPrompt
|
||||||
|
private lateinit var promptInfo: BiometricPrompt.PromptInfo
|
||||||
|
|
||||||
|
// This provides [allowedAuthenticators] on the current user device according to Android Compatibility Definition
|
||||||
|
// Document (CDD). See https://source.android.com/docs/compatibility/cdd
|
||||||
|
private val allowedAuthenticators: Int =
|
||||||
|
when {
|
||||||
|
// Android SDK version == 27
|
||||||
|
(AndroidApiVersion.isExactlyO) -> Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
|
||||||
|
// Android SDK version >= 30
|
||||||
|
(AndroidApiVersion.isAtLeastR) -> Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
|
||||||
|
// Android SDK version == 28 || 29
|
||||||
|
(AndroidApiVersion.isExactlyP || AndroidApiVersion.isExactlyQ) ->
|
||||||
|
Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL
|
||||||
|
else -> error("Unsupported Android SDK version")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome animation display state
|
||||||
|
*/
|
||||||
|
internal val showWelcomeAnimation: MutableStateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
|
internal fun setWelcomeAnimationDisplayed() {
|
||||||
|
showWelcomeAnimation.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App access authentication logic values
|
||||||
|
*/
|
||||||
|
private val isAppAccessAuthenticationRequired: StateFlow<Boolean?> =
|
||||||
|
booleanStateFlow(StandardPreferenceKeys.IS_APP_ACCESS_AUTHENTICATION)
|
||||||
|
|
||||||
|
internal val appAccessAuthentication: MutableStateFlow<AuthenticationUIState> =
|
||||||
|
MutableStateFlow(AuthenticationUIState.Initial)
|
||||||
|
|
||||||
|
internal val appAccessAuthenticationResultState: StateFlow<AuthenticationUIState> =
|
||||||
|
combine(
|
||||||
|
isAppAccessAuthenticationRequired.filterNotNull(),
|
||||||
|
appAccessAuthentication,
|
||||||
|
) { required: Boolean, state: AuthenticationUIState ->
|
||||||
|
when {
|
||||||
|
!required -> AuthenticationUIState.NotRequired
|
||||||
|
state == AuthenticationUIState.Initial -> AuthenticationUIState.Required
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
||||||
|
AuthenticationUIState.Initial
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other authentication use cases
|
||||||
|
*/
|
||||||
|
val isExportPrivateDataAuthenticationRequired: StateFlow<Boolean?> =
|
||||||
|
booleanStateFlow(StandardPreferenceKeys.IS_EXPORT_PRIVATE_DATA_AUTHENTICATION)
|
||||||
|
|
||||||
|
val isDeleteWalletAuthenticationRequired: StateFlow<Boolean?> =
|
||||||
|
booleanStateFlow(StandardPreferenceKeys.IS_DELETE_WALLET_AUTHENTICATION)
|
||||||
|
|
||||||
|
val isSeedAuthenticationRequired: StateFlow<Boolean?> =
|
||||||
|
booleanStateFlow(StandardPreferenceKeys.IS_SEED_AUTHENTICATION)
|
||||||
|
|
||||||
|
val isSendFundsAuthenticationRequired: StateFlow<Boolean?> =
|
||||||
|
booleanStateFlow(StandardPreferenceKeys.IS_SEND_FUNDS_AUTHENTICATION)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication framework result
|
||||||
|
*/
|
||||||
|
internal val authenticationResult: MutableStateFlow<AuthenticationResult> =
|
||||||
|
MutableStateFlow(AuthenticationResult.None)
|
||||||
|
|
||||||
|
internal fun resetAuthenticationResult() {
|
||||||
|
authenticationResult.value = AuthenticationResult.None
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticate(
|
||||||
|
activity: MainActivity,
|
||||||
|
initialAuthSystemWindowDelay: Duration = DEFAULT_INITIAL_DELAY.milliseconds,
|
||||||
|
useCase: AuthenticationUseCase
|
||||||
|
) {
|
||||||
|
val biometricsSupportResult = getBiometricAuthenticationSupport(allowedAuthenticators)
|
||||||
|
Twig.debug { "Authentication getBiometricAuthenticationSupport: $biometricsSupportResult" }
|
||||||
|
|
||||||
|
when (biometricsSupportResult) {
|
||||||
|
BiometricSupportResult.Success -> {
|
||||||
|
// No action needed, let user proceed to the authentication steps
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Otherwise biometric authentication might not be available, but users still can use the
|
||||||
|
// device credential authentication path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
biometricPrompt =
|
||||||
|
BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
/**
|
||||||
|
* Called when an unrecoverable error has been encountered and authentication has stopped.
|
||||||
|
*
|
||||||
|
* After this method is called, no further events will be sent for the current
|
||||||
|
* authentication session.
|
||||||
|
*
|
||||||
|
* @param errorCode An integer ID associated with the error.
|
||||||
|
* @param errorString A human-readable string that describes the error.
|
||||||
|
*/
|
||||||
|
override fun onAuthenticationError(
|
||||||
|
errorCode: Int,
|
||||||
|
errorString: CharSequence
|
||||||
|
) {
|
||||||
|
super.onAuthenticationError(errorCode, errorString)
|
||||||
|
Twig.warn { "Authentication error: $errorCode: $errorString" }
|
||||||
|
|
||||||
|
// Note that we process most of the following authentication errors the same. A potential
|
||||||
|
// improvement in the future could be let user take a different action for a different error.
|
||||||
|
|
||||||
|
// All available error codes are implemented
|
||||||
|
@SuppressLint("SwitchIntDef")
|
||||||
|
when (errorCode) {
|
||||||
|
// The hardware is unavailable. Try again later
|
||||||
|
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||||
|
// The sensor was unable to process the current image
|
||||||
|
BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
|
||||||
|
// The current operation has been running too long and has timed out. This is intended to
|
||||||
|
// prevent programs from waiting for the biometric sensor indefinitely. The timeout is
|
||||||
|
// platform and sensor-specific, but is generally on the order of ~30 seconds.
|
||||||
|
BiometricPrompt.ERROR_TIMEOUT,
|
||||||
|
// The operation can't be completed because there is not enough device storage remaining
|
||||||
|
BiometricPrompt.ERROR_NO_SPACE,
|
||||||
|
// The operation was canceled because the API is locked out due to too many attempts. This
|
||||||
|
// occurs after 5 failed attempts, and lasts for 30 seconds.
|
||||||
|
BiometricPrompt.ERROR_LOCKOUT,
|
||||||
|
// The operation failed due to a vendor-specific error. This error code may be used by
|
||||||
|
// hardware vendors to extend this list to cover errors that don't fall under one of the
|
||||||
|
// other predefined categories. Vendors are responsible for providing the strings for these
|
||||||
|
// errors. These messages are typically reserved for internal operations such as enrollment
|
||||||
|
// but may be used to express any error that is not otherwise covered. In this case,
|
||||||
|
// applications are expected to show the error message, but they are advised not to rely on
|
||||||
|
// the message ID, since this may vary by vendor and device.
|
||||||
|
BiometricPrompt.ERROR_VENDOR,
|
||||||
|
// Biometric authentication is disabled until the user unlocks with their device credential
|
||||||
|
// (i.e. PIN, pattern, or password).
|
||||||
|
BiometricPrompt.ERROR_LOCKOUT_PERMANENT,
|
||||||
|
// The user does not have any biometrics enrolled
|
||||||
|
BiometricPrompt.ERROR_NO_BIOMETRICS,
|
||||||
|
// The device does not have the required authentication hardware
|
||||||
|
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
||||||
|
// The user pressed the negative button
|
||||||
|
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
|
||||||
|
// A security vulnerability has been discovered with one or more hardware sensors. The
|
||||||
|
// affected sensor(s) are unavailable until a security update has addressed the issue
|
||||||
|
BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED -> {
|
||||||
|
authenticationResult.value =
|
||||||
|
AuthenticationResult.Error(errorCode, errorString.toString())
|
||||||
|
}
|
||||||
|
// The user canceled the operation. Upon receiving this, applications should use alternate
|
||||||
|
// authentication, such as a password. The application should also provide the user a way of
|
||||||
|
// returning to biometric authentication, such as a button. The operation was canceled
|
||||||
|
// because [BiometricPrompt.ERROR_LOCKOUT] occurred too many times.
|
||||||
|
BiometricPrompt.ERROR_USER_CANCELED -> {
|
||||||
|
authenticationResult.value = AuthenticationResult.Canceled
|
||||||
|
// The following values are just for testing purposes, so we can easier reproduce other
|
||||||
|
// non-success results obtained from [BiometricPrompt]
|
||||||
|
// = AuthenticationResult.Failed
|
||||||
|
// = AuthenticationResult.Error(errorCode, errorString.toString())
|
||||||
|
}
|
||||||
|
// The operation was canceled because the biometric sensor is unavailable. This may happen
|
||||||
|
// when user is switched, the device is locked, or another pending operation prevents it.
|
||||||
|
BiometricPrompt.ERROR_CANCELED -> {
|
||||||
|
// We could consider splitting ERROR_CANCELED from ERROR_USER_CANCELED
|
||||||
|
authenticationResult.value = AuthenticationResult.Canceled
|
||||||
|
}
|
||||||
|
// The device does not have pin, pattern, or password set up
|
||||||
|
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
||||||
|
// Allow unauthenticated access if no authentication method is available on the device
|
||||||
|
authenticationResult.value = AuthenticationResult.Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a biometric (e.g. fingerprint, face, etc.) is recognized, indicating that the
|
||||||
|
* user has successfully authenticated.
|
||||||
|
*
|
||||||
|
* <p>After this method is called, no further events will be sent for the current
|
||||||
|
* authentication session.
|
||||||
|
*
|
||||||
|
* @param result An object containing authentication-related data.
|
||||||
|
*/
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
Twig.info { "Authentication successful: $result" }
|
||||||
|
authenticationResult.value = AuthenticationResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a biometric (e.g. fingerprint, face, etc.) is presented but not recognized as
|
||||||
|
* belonging to the user.
|
||||||
|
*/
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
super.onAuthenticationFailed()
|
||||||
|
Twig.error { "Authentication failed" }
|
||||||
|
authenticationResult.value = AuthenticationResult.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
promptInfo =
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(
|
||||||
|
application.applicationContext.run {
|
||||||
|
getString(R.string.authentication_system_ui_title, getString(R.string.app_name))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setSubtitle(
|
||||||
|
application.applicationContext.run {
|
||||||
|
getString(
|
||||||
|
R.string.authentication_system_ui_subtitle,
|
||||||
|
getString(
|
||||||
|
when (useCase) {
|
||||||
|
AuthenticationUseCase.AppAccess ->
|
||||||
|
R.string.app_name
|
||||||
|
AuthenticationUseCase.DeleteWallet ->
|
||||||
|
R.string.authentication_use_case_delete_wallet
|
||||||
|
AuthenticationUseCase.ExportPrivateData ->
|
||||||
|
R.string.authentication_use_case_export_data
|
||||||
|
AuthenticationUseCase.SeedRecovery ->
|
||||||
|
R.string.authentication_use_case_seed_recovery
|
||||||
|
AuthenticationUseCase.SendFunds ->
|
||||||
|
R.string.authentication_use_case_send_funds
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setConfirmationRequired(false)
|
||||||
|
.setAllowedAuthenticators(allowedAuthenticators)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// TODO [#7]: Consider integrating with the keystore to unlock cryptographic operations
|
||||||
|
// TODO [#7]: https://github.com/Electric-Coin-Company/zashi/issues/7
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(initialAuthSystemWindowDelay)
|
||||||
|
biometricPrompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBiometricAuthenticationSupport(allowedAuthenticators: Int): BiometricSupportResult {
|
||||||
|
val biometricManager = BiometricManager.from(application)
|
||||||
|
|
||||||
|
return when (biometricManager.canAuthenticate(allowedAuthenticators)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||||
|
Twig.debug { "Auth canAuthenticate BIOMETRIC_SUCCESS: App can authenticate using biometrics." }
|
||||||
|
BiometricSupportResult.Success
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
|
||||||
|
Twig.info {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_ERROR_NO_HARDWARE: No biometric features available on " +
|
||||||
|
"this device."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.ErrorNoHardware
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
|
||||||
|
Twig.error {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_ERROR_HW_UNAVAILABLE: Biometric features are currently " +
|
||||||
|
"unavailable."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.ErrorHwUnavailable
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
|
||||||
|
Twig.warn {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_ERROR_NONE_ENROLLED: Prompts the user to create " +
|
||||||
|
"credentials that your app accepts."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.ErrorNoneEnrolled
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
|
||||||
|
Twig.error {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED: The user can't authenticate " +
|
||||||
|
"because a security vulnerability has been discovered with one or more hardware sensors. The " +
|
||||||
|
"affected sensor(s) are unavailable until a security update has addressed the issue."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.ErrorSecurityUpdateRequired
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
|
||||||
|
Twig.error {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_ERROR_UNSUPPORTED: The user can't authenticate because " +
|
||||||
|
"the specified options are incompatible with the current Android version."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.ErrorUnsupported
|
||||||
|
}
|
||||||
|
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
|
||||||
|
Twig.error {
|
||||||
|
"Auth canAuthenticate BIOMETRIC_STATUS_UNKNOWN: Unable to determine whether the user can" +
|
||||||
|
" authenticate. This status code may be returned on older Android versions due to partial " +
|
||||||
|
"incompatibility with a newer API. Applications that wish to enable biometric authentication " +
|
||||||
|
"on affected devices may still call BiometricPrompt#authenticate() after receiving this " +
|
||||||
|
"status code but should be prepared to handle possible errors."
|
||||||
|
}
|
||||||
|
BiometricSupportResult.StatusUnknown
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Twig.error { "Unexpected biometric framework status" }
|
||||||
|
BiometricSupportResult.StatusExpected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
class AuthenticationViewModelFactory(
|
||||||
|
private val application: Application
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(AuthenticationViewModel::class.java)) { "ViewModel Not Found." }
|
||||||
|
return AuthenticationViewModel(application) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
|
||||||
|
flow<Boolean?> {
|
||||||
|
val preferenceProvider = StandardPreferenceSingleton.getInstance(getApplication())
|
||||||
|
emitAll(default.observe(preferenceProvider))
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AuthenticationUIState {
|
||||||
|
data object Initial : AuthenticationUIState()
|
||||||
|
|
||||||
|
data object Required : AuthenticationUIState()
|
||||||
|
|
||||||
|
data object NotRequired : AuthenticationUIState()
|
||||||
|
|
||||||
|
data object SupportedRequired : AuthenticationUIState()
|
||||||
|
|
||||||
|
data object Successful : AuthenticationUIState()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AuthenticationResult {
|
||||||
|
data object None : AuthenticationResult()
|
||||||
|
|
||||||
|
data object Success : AuthenticationResult()
|
||||||
|
|
||||||
|
data class Error(val errorCode: Int, val errorMessage: String) : AuthenticationResult()
|
||||||
|
|
||||||
|
data object Canceled : AuthenticationResult()
|
||||||
|
|
||||||
|
data object Failed : AuthenticationResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BiometricSupportResult {
|
||||||
|
data object Success : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object ErrorNoHardware : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object ErrorHwUnavailable : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object ErrorNoneEnrolled : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object ErrorSecurityUpdateRequired : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object ErrorUnsupported : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object StatusUnknown : BiometricSupportResult()
|
||||||
|
|
||||||
|
data object StatusExpected : BiometricSupportResult()
|
||||||
|
}
|
|
@ -41,4 +41,33 @@ object StandardPreferenceKeys {
|
||||||
* The fiat currency that the user prefers.
|
* The fiat currency that the user prefers.
|
||||||
*/
|
*/
|
||||||
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(PreferenceKey("preferred_fiat_currency_code"))
|
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(PreferenceKey("preferred_fiat_currency_code"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screens or flows protected by required authentication
|
||||||
|
*/
|
||||||
|
val IS_APP_ACCESS_AUTHENTICATION =
|
||||||
|
BooleanPreferenceDefault(
|
||||||
|
PreferenceKey("IS_APP_ACCESS_AUTHENTICATION"),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val IS_DELETE_WALLET_AUTHENTICATION =
|
||||||
|
BooleanPreferenceDefault(
|
||||||
|
PreferenceKey("IS_DELETE_WALLET_AUTHENTICATION"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val IS_EXPORT_PRIVATE_DATA_AUTHENTICATION =
|
||||||
|
BooleanPreferenceDefault(
|
||||||
|
PreferenceKey("IS_EXPORT_PRIVATE_DATA_AUTHENTICATION"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val IS_SEED_AUTHENTICATION =
|
||||||
|
BooleanPreferenceDefault(
|
||||||
|
PreferenceKey("IS_SEED_AUTHENTICATION"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val IS_SEND_FUNDS_AUTHENTICATION =
|
||||||
|
BooleanPreferenceDefault(
|
||||||
|
PreferenceKey("IS_SEND_FUNDS_AUTHENTICATION"),
|
||||||
|
true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,507 @@
|
||||||
|
@file:Suppress("ktlint:standard:filename")
|
||||||
|
|
||||||
|
package co.electriccoin.zcash.ui.screen.authentication
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import co.electriccoin.zcash.spackle.Twig
|
||||||
|
import co.electriccoin.zcash.ui.MainActivity
|
||||||
|
import co.electriccoin.zcash.ui.R
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationResult
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.view.AppAccessAuthentication
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.view.AuthenticationErrorDialog
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.view.AuthenticationFailedDialog
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val APP_ACCESS_TRIGGER_DELAY = 0
|
||||||
|
private const val DELETE_WALLET_TRIGGER_DELAY = 0
|
||||||
|
private const val EXPORT_PRIVATE_DATA_TRIGGER_DELAY = 0
|
||||||
|
private const val SEED_RECOVERY_TRIGGER_DELAY = 0
|
||||||
|
private const val SEND_FUNDS_DELAY = 0
|
||||||
|
private const val RETRY_TRIGGER_DELAY = 0
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MainActivity.WrapAuthentication(
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
onSuccess: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
useCase: AuthenticationUseCase,
|
||||||
|
) {
|
||||||
|
WrapAuthenticationUseCases(
|
||||||
|
activity = this,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onSuccess = onSuccess,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed,
|
||||||
|
useCase = useCase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
private fun WrapAuthenticationUseCases(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
onSuccess: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
useCase: AuthenticationUseCase,
|
||||||
|
) {
|
||||||
|
when (useCase) {
|
||||||
|
AuthenticationUseCase.AppAccess -> {
|
||||||
|
Twig.debug { "App Access Authentication" }
|
||||||
|
WrapAppAccessAuth(
|
||||||
|
activity = activity,
|
||||||
|
goToAppContent = onSuccess,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUseCase.ExportPrivateData -> {
|
||||||
|
Twig.debug { "Export Private Data Authentication" }
|
||||||
|
WrapAppExportPrivateDataAuth(
|
||||||
|
activity = activity,
|
||||||
|
goExportPrivateData = onSuccess,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUseCase.DeleteWallet -> {
|
||||||
|
Twig.debug { "Delete Wallet Authentication" }
|
||||||
|
WrapDeleteWalletAuth(
|
||||||
|
activity = activity,
|
||||||
|
goDeleteWallet = onSuccess,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUseCase.SeedRecovery -> {
|
||||||
|
Twig.debug { "Seed Recovery Authentication" }
|
||||||
|
WrapSeedRecoveryAuth(
|
||||||
|
activity = activity,
|
||||||
|
goSeedRecovery = onSuccess,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthenticationUseCase.SendFunds -> {
|
||||||
|
Twig.debug { "Send Funds Authentication" }
|
||||||
|
WrapSendFundsAuth(
|
||||||
|
activity = activity,
|
||||||
|
onSendFunds = onSuccess,
|
||||||
|
goSupport = goSupport,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onFailed = onFailed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WrapDeleteWalletAuth(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
goDeleteWallet: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val authenticationViewModel by activity.viewModels<AuthenticationViewModel>()
|
||||||
|
|
||||||
|
val authenticationResult =
|
||||||
|
authenticationViewModel.authenticationResult
|
||||||
|
.collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value
|
||||||
|
|
||||||
|
when (authenticationResult) {
|
||||||
|
AuthenticationResult.None -> {
|
||||||
|
Twig.info { "Authentication result: initiating" }
|
||||||
|
// Initial state
|
||||||
|
}
|
||||||
|
AuthenticationResult.Success -> {
|
||||||
|
Twig.info { "Authentication result: successful" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goDeleteWallet()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Canceled -> {
|
||||||
|
Twig.info { "Authentication result: canceled" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Failed -> {
|
||||||
|
Twig.warn { "Authentication result: failed" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onFailed()
|
||||||
|
Toast.makeText(activity, activity.getString(R.string.authentication_toast_failed), Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
is AuthenticationResult.Error -> {
|
||||||
|
Twig.error {
|
||||||
|
"Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}"
|
||||||
|
}
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {
|
||||||
|
// Reset authentication states
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.DeleteWallet
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
reason = authenticationResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting authentication
|
||||||
|
LaunchedEffect(key1 = true) {
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = DELETE_WALLET_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.DeleteWallet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WrapAppExportPrivateDataAuth(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
goExportPrivateData: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val authenticationViewModel by activity.viewModels<AuthenticationViewModel>()
|
||||||
|
|
||||||
|
val authenticationResult =
|
||||||
|
authenticationViewModel.authenticationResult
|
||||||
|
.collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value
|
||||||
|
|
||||||
|
when (authenticationResult) {
|
||||||
|
AuthenticationResult.None -> {
|
||||||
|
Twig.info { "Authentication result: initiating" }
|
||||||
|
// Initial state
|
||||||
|
}
|
||||||
|
AuthenticationResult.Success -> {
|
||||||
|
Twig.info { "Authentication result: successful" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goExportPrivateData()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Canceled -> {
|
||||||
|
Twig.info { "Authentication result: canceled" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Failed -> {
|
||||||
|
Twig.warn { "Authentication result: failed" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onFailed()
|
||||||
|
Toast.makeText(activity, stringResource(id = R.string.authentication_toast_failed), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
is AuthenticationResult.Error -> {
|
||||||
|
Twig.error {
|
||||||
|
"Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}"
|
||||||
|
}
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {
|
||||||
|
// Reset authentication states
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.ExportPrivateData
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
reason = authenticationResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting authentication
|
||||||
|
LaunchedEffect(key1 = true) {
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = EXPORT_PRIVATE_DATA_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.ExportPrivateData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WrapSeedRecoveryAuth(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
goSeedRecovery: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val authenticationViewModel by activity.viewModels<AuthenticationViewModel>()
|
||||||
|
|
||||||
|
val authenticationResult =
|
||||||
|
authenticationViewModel.authenticationResult
|
||||||
|
.collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value
|
||||||
|
|
||||||
|
when (authenticationResult) {
|
||||||
|
AuthenticationResult.None -> {
|
||||||
|
Twig.info { "Authentication result: initiating" }
|
||||||
|
// Initial state
|
||||||
|
}
|
||||||
|
AuthenticationResult.Success -> {
|
||||||
|
Twig.info { "Authentication result: successful" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSeedRecovery()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Canceled -> {
|
||||||
|
Twig.info { "Authentication result: canceled" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Failed -> {
|
||||||
|
Twig.warn { "Authentication result: failed" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onFailed()
|
||||||
|
Toast.makeText(activity, stringResource(id = R.string.authentication_toast_failed), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
is AuthenticationResult.Error -> {
|
||||||
|
Twig.error {
|
||||||
|
"Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}"
|
||||||
|
}
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {
|
||||||
|
// Reset authentication states
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.SeedRecovery
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
reason = authenticationResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting authentication
|
||||||
|
LaunchedEffect(key1 = true) {
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = SEED_RECOVERY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.SeedRecovery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
private fun WrapSendFundsAuth(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
onSendFunds: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val authenticationViewModel by activity.viewModels<AuthenticationViewModel>()
|
||||||
|
|
||||||
|
val authenticationResult =
|
||||||
|
authenticationViewModel.authenticationResult
|
||||||
|
.collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value
|
||||||
|
|
||||||
|
when (authenticationResult) {
|
||||||
|
AuthenticationResult.None -> {
|
||||||
|
Twig.info { "Authentication result: initiating" }
|
||||||
|
// Initial state
|
||||||
|
}
|
||||||
|
AuthenticationResult.Success -> {
|
||||||
|
Twig.info { "Authentication result: successful" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onSendFunds()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Canceled -> {
|
||||||
|
Twig.info { "Authentication result: canceled" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Failed -> {
|
||||||
|
Twig.warn { "Authentication result: failed" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onFailed()
|
||||||
|
Toast.makeText(activity, stringResource(id = R.string.authentication_toast_failed), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
is AuthenticationResult.Error -> {
|
||||||
|
Twig.error {
|
||||||
|
"Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}"
|
||||||
|
}
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {
|
||||||
|
// Reset authentication states
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.SendFunds
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
reason = authenticationResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting authentication
|
||||||
|
LaunchedEffect(key1 = true) {
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = SEND_FUNDS_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.SendFunds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
private fun WrapAppAccessAuth(
|
||||||
|
activity: MainActivity,
|
||||||
|
goSupport: () -> Unit,
|
||||||
|
goToAppContent: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onFailed: () -> Unit,
|
||||||
|
) {
|
||||||
|
val authenticationViewModel by activity.viewModels<AuthenticationViewModel>()
|
||||||
|
|
||||||
|
val welcomeAnimVisibility = authenticationViewModel.showWelcomeAnimation.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
|
AppAccessAuthentication(welcomeAnimVisibility = welcomeAnimVisibility)
|
||||||
|
|
||||||
|
val authenticationResult =
|
||||||
|
authenticationViewModel.authenticationResult
|
||||||
|
.collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value
|
||||||
|
|
||||||
|
when (authenticationResult) {
|
||||||
|
AuthenticationResult.None -> {
|
||||||
|
Twig.debug { "Authentication result: initiating" }
|
||||||
|
// Initial state
|
||||||
|
}
|
||||||
|
AuthenticationResult.Success -> {
|
||||||
|
Twig.debug { "Authentication result: successful" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.setWelcomeAnimationDisplayed()
|
||||||
|
goToAppContent()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Canceled -> {
|
||||||
|
Twig.info { "Authentication result: canceled: shutting down" }
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
Toast.makeText(activity, stringResource(id = R.string.authentication_toast_canceled), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
AuthenticationResult.Failed -> {
|
||||||
|
Twig.warn { "Authentication result: failed" }
|
||||||
|
onFailed()
|
||||||
|
AuthenticationFailedDialog(
|
||||||
|
onDismiss = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.AppAccess
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AuthenticationResult.Error -> {
|
||||||
|
Twig.error {
|
||||||
|
"Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}"
|
||||||
|
}
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.AppAccess
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSupport = {
|
||||||
|
authenticationViewModel.resetAuthenticationResult()
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
reason = authenticationResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting authentication
|
||||||
|
LaunchedEffect(key1 = true) {
|
||||||
|
authenticationViewModel.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
initialAuthSystemWindowDelay = APP_ACCESS_TRIGGER_DELAY.milliseconds,
|
||||||
|
useCase = AuthenticationUseCase.AppAccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AuthenticationUseCase {
|
||||||
|
data object AppAccess : AuthenticationUseCase()
|
||||||
|
|
||||||
|
data object SeedRecovery : AuthenticationUseCase()
|
||||||
|
|
||||||
|
data object DeleteWallet : AuthenticationUseCase()
|
||||||
|
|
||||||
|
data object ExportPrivateData : AuthenticationUseCase()
|
||||||
|
|
||||||
|
data object SendFunds : AuthenticationUseCase()
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
package co.electriccoin.zcash.ui.screen.authentication.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import co.electriccoin.zcash.ui.R
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationResult
|
||||||
|
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
|
||||||
|
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
|
import co.electriccoin.zcash.ui.design.component.WelcomeAnimation
|
||||||
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
|
||||||
|
@Preview("App Access Authentication")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewAppAccessAuthentication() {
|
||||||
|
ZcashTheme(forceDarkMode = false) {
|
||||||
|
GradientSurface {
|
||||||
|
AppAccessAuthentication(
|
||||||
|
welcomeAnimVisibility = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview("Error Authentication")
|
||||||
|
@Composable
|
||||||
|
private fun PreviewErrorAuthentication() {
|
||||||
|
ZcashTheme(forceDarkMode = false) {
|
||||||
|
GradientSurface {
|
||||||
|
AuthenticationErrorDialog(
|
||||||
|
onDismiss = {},
|
||||||
|
onRetry = {},
|
||||||
|
onSupport = {},
|
||||||
|
reason = AuthenticationResult.Error(errorCode = -1, errorMessage = "Test Error Message")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppAccessAuthentication(
|
||||||
|
welcomeAnimVisibility: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
WelcomeAnimation(
|
||||||
|
animationState = welcomeAnimVisibility,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthenticationErrorDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSupport: () -> Unit,
|
||||||
|
reason: AuthenticationResult.Error
|
||||||
|
) {
|
||||||
|
AppAlertDialog(
|
||||||
|
title = stringResource(id = R.string.authentication_error_title),
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
Modifier.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.authentication_error_text))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
id = R.string.authentication_error_details,
|
||||||
|
reason.errorCode,
|
||||||
|
reason.errorMessage,
|
||||||
|
),
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButtonText = stringResource(id = R.string.authentication_error_button_retry),
|
||||||
|
onConfirmButtonClick = onRetry,
|
||||||
|
dismissButtonText = stringResource(id = R.string.authentication_error_button_support),
|
||||||
|
onDismissButtonClick = onSupport,
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthenticationFailedDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSupport: () -> Unit
|
||||||
|
) {
|
||||||
|
AppAlertDialog(
|
||||||
|
title = stringResource(id = R.string.authentication_failed_title),
|
||||||
|
text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(text = stringResource(id = R.string.authentication_failed_text))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButtonText = stringResource(id = R.string.authentication_failed_button_retry),
|
||||||
|
onConfirmButtonClick = onRetry,
|
||||||
|
dismissButtonText = stringResource(id = R.string.authentication_failed_button_support),
|
||||||
|
onDismissButtonClick = onSupport,
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
|
@ -127,6 +127,19 @@ private fun ComposableBalancesShieldFailurePreview() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview("BalancesShieldErrorDialog")
|
||||||
|
@Composable
|
||||||
|
private fun ComposableBalancesShieldErrorDialogPreview() {
|
||||||
|
ZcashTheme(forceDarkMode = false) {
|
||||||
|
GradientSurface {
|
||||||
|
ShieldingErrorDialog(
|
||||||
|
reason = "Test Error Text",
|
||||||
|
onDone = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
@Composable
|
@Composable
|
||||||
fun Balances(
|
fun Balances(
|
||||||
|
|
|
@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.screen.exportdata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
@ -38,7 +39,7 @@ internal fun MainActivity.WrapExportPrivateData(
|
||||||
|
|
||||||
WrapExportPrivateData(
|
WrapExportPrivateData(
|
||||||
this,
|
this,
|
||||||
onBack = goBack,
|
goBack = goBack,
|
||||||
onShare = onConfirm,
|
onShare = onConfirm,
|
||||||
synchronizer = synchronizer,
|
synchronizer = synchronizer,
|
||||||
walletRestoringState = walletRestoringState,
|
walletRestoringState = walletRestoringState,
|
||||||
|
@ -48,11 +49,15 @@ internal fun MainActivity.WrapExportPrivateData(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WrapExportPrivateData(
|
internal fun WrapExportPrivateData(
|
||||||
activity: ComponentActivity,
|
activity: ComponentActivity,
|
||||||
onBack: () -> Unit,
|
goBack: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
synchronizer: Synchronizer?,
|
synchronizer: Synchronizer?,
|
||||||
walletRestoringState: WalletRestoringState,
|
walletRestoringState: WalletRestoringState,
|
||||||
) {
|
) {
|
||||||
|
BackHandler {
|
||||||
|
goBack()
|
||||||
|
}
|
||||||
|
|
||||||
if (synchronizer == null) {
|
if (synchronizer == null) {
|
||||||
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
|
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
|
||||||
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
|
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
|
||||||
|
@ -64,7 +69,7 @@ internal fun WrapExportPrivateData(
|
||||||
|
|
||||||
ExportPrivateData(
|
ExportPrivateData(
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
onBack = onBack,
|
onBack = goBack,
|
||||||
onAgree = {
|
onAgree = {
|
||||||
// Needed for UI testing only
|
// Needed for UI testing only
|
||||||
},
|
},
|
||||||
|
|
|
@ -21,7 +21,7 @@ import co.electriccoin.zcash.ui.common.model.VersionInfo
|
||||||
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.chooseserver.AvailableServerProvider
|
import co.electriccoin.zcash.ui.screen.chooseserver.AvailableServerProvider
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.restore.WrapRestore
|
import co.electriccoin.zcash.ui.screen.restore.WrapRestore
|
||||||
|
|
||||||
|
@ -40,10 +40,10 @@ internal fun WrapOnboarding(activity: ComponentActivity) {
|
||||||
|
|
||||||
// TODO [#383]: https://github.com/Electric-Coin-Company/zashi-android/issues/383
|
// TODO [#383]: https://github.com/Electric-Coin-Company/zashi-android/issues/383
|
||||||
// TODO [#383]: Refactoring of UI state retention into rememberSaveable fields
|
// TODO [#383]: Refactoring of UI state retention into rememberSaveable fields
|
||||||
|
|
||||||
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
|
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
|
||||||
val onCreateWallet = {
|
val onCreateWallet = {
|
||||||
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
|
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
|
||||||
onboardingViewModel.setShowWelcomeAnimation(false)
|
|
||||||
}
|
}
|
||||||
val onImportWallet = {
|
val onImportWallet = {
|
||||||
// In the case of the app currently being messed with by the robo test runner on
|
// In the case of the app currently being messed with by the robo test runner on
|
||||||
|
@ -60,8 +60,6 @@ internal fun WrapOnboarding(activity: ComponentActivity) {
|
||||||
} else {
|
} else {
|
||||||
onboardingViewModel.setIsImporting(true)
|
onboardingViewModel.setIsImporting(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
onboardingViewModel.setShowWelcomeAnimation(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val onFixtureWallet: (String) -> Unit = { seed ->
|
val onFixtureWallet: (String) -> Unit = { seed ->
|
||||||
|
@ -73,10 +71,7 @@ internal fun WrapOnboarding(activity: ComponentActivity) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val showWelcomeAnimation = onboardingViewModel.showWelcomeAnimation.collectAsStateWithLifecycle().value
|
Onboarding(
|
||||||
|
|
||||||
ShortOnboarding(
|
|
||||||
showWelcomeAnim = showWelcomeAnimation,
|
|
||||||
isDebugMenuEnabled = versionInfo.isDebuggable && !versionInfo.isRunningUnderTestService,
|
isDebugMenuEnabled = versionInfo.isDebuggable && !versionInfo.isRunningUnderTestService,
|
||||||
onImportWallet = onImportWallet,
|
onImportWallet = onImportWallet,
|
||||||
onCreateWallet = onCreateWallet,
|
onCreateWallet = onCreateWallet,
|
||||||
|
|
|
@ -2,11 +2,8 @@
|
||||||
|
|
||||||
package co.electriccoin.zcash.ui.screen.onboarding.view
|
package co.electriccoin.zcash.ui.screen.onboarding.view
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
@ -17,51 +14,30 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import cash.z.ecc.android.sdk.fixture.WalletFixture
|
import cash.z.ecc.android.sdk.fixture.WalletFixture
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
||||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
||||||
import co.electriccoin.zcash.ui.design.component.SecondaryButton
|
import co.electriccoin.zcash.ui.design.component.SecondaryButton
|
||||||
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
|
|
||||||
import co.electriccoin.zcash.ui.design.component.TitleLarge
|
import co.electriccoin.zcash.ui.design.component.TitleLarge
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
import co.electriccoin.zcash.ui.design.util.ScreenHeight
|
|
||||||
import co.electriccoin.zcash.ui.design.util.screenHeight
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Preview("ShortOnboarding")
|
@Preview("Onboarding")
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShortOnboardingComposablePreview() {
|
private fun OnboardingComposablePreview() {
|
||||||
ZcashTheme(forceDarkMode = false) {
|
ZcashTheme(forceDarkMode = false) {
|
||||||
GradientSurface {
|
GradientSurface {
|
||||||
ShortOnboarding(
|
Onboarding(
|
||||||
showWelcomeAnim = false,
|
isDebugMenuEnabled = true,
|
||||||
isDebugMenuEnabled = false,
|
|
||||||
onImportWallet = {},
|
onImportWallet = {},
|
||||||
onCreateWallet = {},
|
onCreateWallet = {},
|
||||||
onFixtureWallet = {}
|
onFixtureWallet = {}
|
||||||
|
@ -77,205 +53,100 @@ private fun ShortOnboardingComposablePreview() {
|
||||||
// TODO [#1001]: https://github.com/Electric-Coin-Company/zashi-android/issues/1001
|
// TODO [#1001]: https://github.com/Electric-Coin-Company/zashi-android/issues/1001
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param showWelcomeAnim Whether the welcome screen growing chart animation should be done or not.
|
|
||||||
* @param onImportWallet Callback when the user decides to import an existing wallet.
|
* @param onImportWallet Callback when the user decides to import an existing wallet.
|
||||||
* @param onCreateWallet Callback when the user decides to create a new wallet.
|
* @param onCreateWallet Callback when the user decides to create a new wallet.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ShortOnboarding(
|
fun Onboarding(
|
||||||
showWelcomeAnim: Boolean,
|
|
||||||
isDebugMenuEnabled: Boolean,
|
isDebugMenuEnabled: Boolean,
|
||||||
onImportWallet: () -> Unit,
|
onImportWallet: () -> Unit,
|
||||||
onCreateWallet: () -> Unit,
|
onCreateWallet: () -> Unit,
|
||||||
onFixtureWallet: (String) -> Unit
|
onFixtureWallet: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
Scaffold { paddingValues ->
|
Scaffold { paddingValues ->
|
||||||
val screenHeight = screenHeight()
|
OnboardingMainContent(
|
||||||
val (welcomeAnimVisibility, setWelcomeAnimVisibility) =
|
isDebugMenuEnabled = isDebugMenuEnabled,
|
||||||
rememberSaveable {
|
onCreateWallet = onCreateWallet,
|
||||||
mutableStateOf(showWelcomeAnim)
|
onFixtureWallet = onFixtureWallet,
|
||||||
}
|
onImportWallet = onImportWallet,
|
||||||
|
modifier =
|
||||||
Column(
|
Modifier
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
.padding(
|
||||||
) {
|
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingHuge,
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
|
||||||
AnimatedImage(
|
start = ZcashTheme.dimens.screenHorizontalSpacingBig,
|
||||||
screenHeight = screenHeight,
|
end = ZcashTheme.dimens.screenHorizontalSpacingBig
|
||||||
welcomeAnimVisibility = welcomeAnimVisibility,
|
)
|
||||||
setWelcomeAnimVisibility = setWelcomeAnimVisibility,
|
)
|
||||||
modifier = Modifier.zIndex(1f)
|
|
||||||
)
|
|
||||||
OnboardingMainContent(
|
|
||||||
isDebugMenuEnabled = isDebugMenuEnabled,
|
|
||||||
onImportWallet = onImportWallet,
|
|
||||||
onCreateWallet = onCreateWallet,
|
|
||||||
onFixtureWallet = onFixtureWallet,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.height(screenHeight.overallScreenHeight())
|
|
||||||
.padding(
|
|
||||||
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingHuge,
|
|
||||||
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingBig,
|
|
||||||
start = ZcashTheme.dimens.screenHorizontalSpacingBig,
|
|
||||||
end = ZcashTheme.dimens.screenHorizontalSpacingBig
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DebugMenu(onFixtureWallet: (String) -> Unit) {
|
|
||||||
Column {
|
|
||||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
|
||||||
IconButton(onClick = { expanded = true }) {
|
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Import Alice's wallet") },
|
|
||||||
onClick = { onFixtureWallet(WalletFixture.Alice.seedPhrase) }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Import Ben's wallet") },
|
|
||||||
onClick = { onFixtureWallet(WalletFixture.Ben.seedPhrase) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnboardingMainContent(
|
private fun OnboardingMainContent(
|
||||||
isDebugMenuEnabled: Boolean,
|
|
||||||
onImportWallet: () -> Unit,
|
onImportWallet: () -> Unit,
|
||||||
onCreateWallet: () -> Unit,
|
onCreateWallet: () -> Unit,
|
||||||
onFixtureWallet: (String) -> Unit,
|
onFixtureWallet: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
isDebugMenuEnabled: Boolean,
|
||||||
) {
|
|
||||||
@Suppress("ModifierNotUsedAtRoot")
|
|
||||||
Box {
|
|
||||||
SmallTopAppBar(
|
|
||||||
regularActions = {
|
|
||||||
if (isDebugMenuEnabled) {
|
|
||||||
DebugMenu(onFixtureWallet)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = modifier.then(Modifier.verticalScroll(rememberScrollState())),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.zashi_logo_without_text),
|
|
||||||
stringResource(R.string.zcash_logo_content_description),
|
|
||||||
Modifier
|
|
||||||
.height(ZcashTheme.dimens.inScreenZcashLogoHeight)
|
|
||||||
.width(ZcashTheme.dimens.inScreenZcashLogoWidth)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
|
||||||
|
|
||||||
Image(
|
|
||||||
painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.zashi_text_logo),
|
|
||||||
""
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
|
|
||||||
|
|
||||||
TitleLarge(text = stringResource(R.string.onboarding_header), textAlign = TextAlign.Center)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
|
||||||
|
|
||||||
Spacer(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.weight(MINIMAL_WEIGHT)
|
|
||||||
)
|
|
||||||
|
|
||||||
PrimaryButton(
|
|
||||||
onClick = onCreateWallet,
|
|
||||||
text = stringResource(R.string.onboarding_create_new_wallet),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
|
||||||
|
|
||||||
SecondaryButton(
|
|
||||||
onImportWallet,
|
|
||||||
stringResource(R.string.onboarding_import_existing_wallet)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AnimatedImage(
|
|
||||||
screenHeight: ScreenHeight,
|
|
||||||
welcomeAnimVisibility: Boolean,
|
|
||||||
setWelcomeAnimVisibility: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// TODO [#1002]: Welcome screen animation masking
|
Column(
|
||||||
// TODO [#1002]: https://github.com/Electric-Coin-Company/zashi-android/issues/1002
|
modifier =
|
||||||
|
Modifier
|
||||||
AnimatedVisibility(
|
.fillMaxSize()
|
||||||
visible = welcomeAnimVisibility,
|
.verticalScroll(rememberScrollState())
|
||||||
exit =
|
.then(modifier),
|
||||||
slideOutVertically(
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
targetOffsetY = { -it },
|
|
||||||
animationSpec = tween(AnimationConstants.ANIMATION_DURATION)
|
|
||||||
),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
var imageModifier =
|
||||||
Column(modifier = Modifier.fillMaxHeight()) {
|
Modifier
|
||||||
Image(
|
.height(ZcashTheme.dimens.inScreenZcashLogoHeight)
|
||||||
painter = ColorPainter(ZcashTheme.colors.welcomeAnimationColor),
|
.width(ZcashTheme.dimens.inScreenZcashLogoWidth)
|
||||||
contentScale = ContentScale.FillBounds,
|
if (isDebugMenuEnabled) {
|
||||||
modifier =
|
imageModifier =
|
||||||
Modifier
|
imageModifier.then(
|
||||||
.fillMaxHeight()
|
Modifier.clickable {
|
||||||
.height(screenHeight.overallScreenHeight() + ZcashTheme.dimens.spacingHuge),
|
onFixtureWallet(WalletFixture.Alice.seedPhrase)
|
||||||
contentDescription = null
|
}
|
||||||
)
|
)
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.chart_line),
|
|
||||||
contentScale = ContentScale.FillBounds,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.logo_with_hi),
|
|
||||||
contentDescription = stringResource(R.string.zcash_logo_with_hi_text_content_description),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = screenHeight.systemStatusBarHeight + ZcashTheme.dimens.spacingHuge)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Using [rememberUpdatedState] to ensure that always the latest lambda is captured
|
Image(
|
||||||
// And to avoid Detekt warning: Lambda parameters in a @Composable that are referenced directly inside of
|
painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.zashi_logo_without_text),
|
||||||
// restarting effects can cause issues or unpredictable behavior.
|
stringResource(R.string.zcash_logo_content_description),
|
||||||
val currentSetWelcomeAnimVisibility = rememberUpdatedState(newValue = setWelcomeAnimVisibility)
|
modifier = imageModifier
|
||||||
|
)
|
||||||
|
|
||||||
LaunchedEffect(currentSetWelcomeAnimVisibility) {
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||||
delay(AnimationConstants.INITIAL_DELAY)
|
|
||||||
currentSetWelcomeAnimVisibility.value(false)
|
Image(
|
||||||
|
painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.zashi_text_logo),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
|
||||||
|
|
||||||
|
TitleLarge(text = stringResource(R.string.onboarding_header), textAlign = TextAlign.Center)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||||
|
|
||||||
|
Spacer(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.weight(MINIMAL_WEIGHT)
|
||||||
|
)
|
||||||
|
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = onCreateWallet,
|
||||||
|
text = stringResource(R.string.onboarding_create_new_wallet),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||||
|
|
||||||
|
SecondaryButton(
|
||||||
|
onImportWallet,
|
||||||
|
stringResource(R.string.onboarding_import_existing_wallet)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object AnimationConstants {
|
|
||||||
const val ANIMATION_DURATION = 1250
|
|
||||||
const val INITIAL_DELAY: Long = 800
|
|
||||||
}
|
|
||||||
|
|
|
@ -21,14 +21,7 @@ class OnboardingViewModel(
|
||||||
savedStateHandle[KEY_IS_IMPORTING] = isImporting
|
savedStateHandle[KEY_IS_IMPORTING] = isImporting
|
||||||
}
|
}
|
||||||
|
|
||||||
val showWelcomeAnimation = savedStateHandle.getStateFlow(KEY_SHOW_WELCOME_ANIMATION, true)
|
|
||||||
|
|
||||||
fun setShowWelcomeAnimation(setShowWelcomeAnimation: Boolean) {
|
|
||||||
savedStateHandle[KEY_SHOW_WELCOME_ANIMATION] = setShowWelcomeAnimation
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_IS_IMPORTING = "is_importing" // $NON-NLS
|
private const val KEY_IS_IMPORTING = "is_importing" // $NON-NLS
|
||||||
private const val KEY_SHOW_WELCOME_ANIMATION = "show_welcome_animation" // $NON-NLS
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -597,6 +597,7 @@ fun ImageAnalysis.qrCodeFlow(
|
||||||
QrCodeAnalyzer(
|
QrCodeAnalyzer(
|
||||||
framePosition = framePosition,
|
framePosition = framePosition,
|
||||||
onQrCodeScanned = { result ->
|
onQrCodeScanned = { result ->
|
||||||
|
Twig.debug { "Scan result onQrCodeScanned: $result" }
|
||||||
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
|
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
|
||||||
// after the view goes away. Collection needs to occur within the Compose lifecycle
|
// after the view goes away. Collection needs to occur within the Compose lifecycle
|
||||||
// to make this not be a problem.
|
// to make this not be a problem.
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package co.electriccoin.zcash.ui.screen.seedrecovery
|
package co.electriccoin.zcash.ui.screen.seedrecovery
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
@ -48,6 +49,10 @@ private fun WrapSeedRecovery(
|
||||||
synchronizer: Synchronizer?,
|
synchronizer: Synchronizer?,
|
||||||
secretState: SecretState,
|
secretState: SecretState,
|
||||||
) {
|
) {
|
||||||
|
BackHandler {
|
||||||
|
goBack()
|
||||||
|
}
|
||||||
|
|
||||||
val versionInfo = VersionInfo.new(activity.applicationContext)
|
val versionInfo = VersionInfo.new(activity.applicationContext)
|
||||||
|
|
||||||
val persistableWallet =
|
val persistableWallet =
|
||||||
|
|
|
@ -4,7 +4,6 @@ package co.electriccoin.zcash.ui.screen.sendconfirmation
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
@ -19,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||||
import cash.z.ecc.android.sdk.Synchronizer
|
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.TransactionSubmitResult
|
||||||
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
|
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
|
||||||
import cash.z.ecc.android.sdk.model.ZecSend
|
import cash.z.ecc.android.sdk.model.ZecSend
|
||||||
|
@ -26,8 +26,11 @@ import co.electriccoin.zcash.spackle.Twig
|
||||||
import co.electriccoin.zcash.ui.MainActivity
|
import co.electriccoin.zcash.ui.MainActivity
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
|
||||||
|
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
||||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||||
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
|
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
|
||||||
|
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
|
||||||
import co.electriccoin.zcash.ui.screen.send.ext.Saver
|
import co.electriccoin.zcash.ui.screen.send.ext.Saver
|
||||||
import co.electriccoin.zcash.ui.screen.sendconfirmation.ext.toSupportString
|
import co.electriccoin.zcash.ui.screen.sendconfirmation.ext.toSupportString
|
||||||
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationArguments
|
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SendConfirmationArguments
|
||||||
|
@ -41,12 +44,14 @@ import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel
|
||||||
import co.electriccoin.zcash.ui.util.EmailUtil
|
import co.electriccoin.zcash.ui.util.EmailUtil
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainActivity.WrapSendConfirmation(
|
internal fun MainActivity.WrapSendConfirmation(
|
||||||
goBack: (clearForm: Boolean) -> Unit,
|
goBack: (clearForm: Boolean) -> Unit,
|
||||||
goHome: () -> Unit,
|
goHome: () -> Unit,
|
||||||
|
goSupport: () -> Unit,
|
||||||
arguments: SendConfirmationArguments
|
arguments: SendConfirmationArguments
|
||||||
) {
|
) {
|
||||||
val walletViewModel by viewModels<WalletViewModel>()
|
val walletViewModel by viewModels<WalletViewModel>()
|
||||||
|
@ -55,6 +60,10 @@ internal fun MainActivity.WrapSendConfirmation(
|
||||||
|
|
||||||
val supportViewModel by viewModels<SupportViewModel>()
|
val supportViewModel by viewModels<SupportViewModel>()
|
||||||
|
|
||||||
|
val authenticationViewModel by viewModels<AuthenticationViewModel> {
|
||||||
|
AuthenticationViewModel.AuthenticationViewModelFactory(application)
|
||||||
|
}
|
||||||
|
|
||||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
|
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
|
||||||
|
@ -66,8 +75,10 @@ internal fun MainActivity.WrapSendConfirmation(
|
||||||
WrapSendConfirmation(
|
WrapSendConfirmation(
|
||||||
activity = this,
|
activity = this,
|
||||||
arguments = arguments,
|
arguments = arguments,
|
||||||
|
authenticationViewModel = authenticationViewModel,
|
||||||
goBack = goBack,
|
goBack = goBack,
|
||||||
goHome = goHome,
|
goHome = goHome,
|
||||||
|
goSupport = goSupport,
|
||||||
createTransactionsViewModel = createTransactionsViewModel,
|
createTransactionsViewModel = createTransactionsViewModel,
|
||||||
spendingKey = spendingKey,
|
spendingKey = spendingKey,
|
||||||
supportMessage = supportMessage,
|
supportMessage = supportMessage,
|
||||||
|
@ -80,10 +91,12 @@ internal fun MainActivity.WrapSendConfirmation(
|
||||||
@Composable
|
@Composable
|
||||||
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
|
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
|
||||||
internal fun WrapSendConfirmation(
|
internal fun WrapSendConfirmation(
|
||||||
activity: ComponentActivity,
|
activity: MainActivity,
|
||||||
arguments: SendConfirmationArguments,
|
arguments: SendConfirmationArguments,
|
||||||
|
authenticationViewModel: AuthenticationViewModel,
|
||||||
goBack: (clearForm: Boolean) -> Unit,
|
goBack: (clearForm: Boolean) -> Unit,
|
||||||
goHome: () -> Unit,
|
goHome: () -> Unit,
|
||||||
|
goSupport: () -> Unit,
|
||||||
createTransactionsViewModel: CreateTransactionsViewModel,
|
createTransactionsViewModel: CreateTransactionsViewModel,
|
||||||
spendingKey: UnifiedSpendingKey?,
|
spendingKey: UnifiedSpendingKey?,
|
||||||
supportMessage: SupportInfo?,
|
supportMessage: SupportInfo?,
|
||||||
|
@ -94,15 +107,12 @@ internal fun WrapSendConfirmation(
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
val zecSend by rememberSaveable(stateSaver = ZecSend.Saver) {
|
// Helper property for triggering the system security UI from callbacks
|
||||||
mutableStateOf(
|
val sendFundsAuthentication = rememberSaveable { mutableStateOf(false) }
|
||||||
if (arguments.hasValidZecSend()) {
|
|
||||||
arguments.toZecSend()
|
val zecSend by rememberSaveable(stateSaver = ZecSend.Saver) { mutableStateOf(arguments.toZecSend()) }
|
||||||
} else {
|
// ZecSend object and all its properties are not-null! We just use the common Send and Send.Confirmation Saver
|
||||||
null
|
checkNotNull(zecSend!!.proposal)
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val (stage, setStage) =
|
val (stage, setStage) =
|
||||||
rememberSaveable(stateSaver = SendConfirmationStage.Saver) {
|
rememberSaveable(stateSaver = SendConfirmationStage.Saver) {
|
||||||
|
@ -133,8 +143,7 @@ internal fun WrapSendConfirmation(
|
||||||
} else {
|
} else {
|
||||||
SendConfirmation(
|
SendConfirmation(
|
||||||
stage = stage,
|
stage = stage,
|
||||||
onStageChange = setStage,
|
zecSend = zecSend!!,
|
||||||
zecSend = zecSend,
|
|
||||||
submissionResults = submissionResults,
|
submissionResults = submissionResults,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
onBack = onBackAction,
|
onBack = onBackAction,
|
||||||
|
@ -168,44 +177,135 @@ internal fun WrapSendConfirmation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCreateAndSend = { newZecSend ->
|
onConfirmation = {
|
||||||
|
// Check and trigger authentication if required, or just submit transactions otherwise
|
||||||
scope.launch {
|
scope.launch {
|
||||||
Twig.debug { "Sending transactions..." }
|
authenticationViewModel.isSendFundsAuthenticationRequired
|
||||||
|
.filterNotNull()
|
||||||
// The not-null assertion operator is necessary here even if we check its nullability before
|
.collect { isProtected ->
|
||||||
// due to property is declared in different module. See more details on the Kotlin forum
|
if (isProtected) {
|
||||||
checkNotNull(newZecSend.proposal)
|
sendFundsAuthentication.value = true
|
||||||
|
} else {
|
||||||
val result =
|
runSendFundsAction(
|
||||||
createTransactionsViewModel.runCreateTransactions(
|
createTransactionsViewModel = createTransactionsViewModel,
|
||||||
synchronizer = synchronizer,
|
goHome = goHome,
|
||||||
spendingKey = spendingKey,
|
// The not-null assertion operator is necessary here even if we check its
|
||||||
proposal = newZecSend.proposal!!
|
// nullability before due to property is declared in different module. See more
|
||||||
)
|
// details on the Kotlin forum
|
||||||
|
proposal = zecSend!!.proposal!!,
|
||||||
// Triggering the transaction history and balances refresh to be notified immediately
|
setStage = setStage,
|
||||||
// about the wallet's updated state
|
spendingKey = spendingKey,
|
||||||
(synchronizer as SdkSynchronizer).run {
|
synchronizer = synchronizer,
|
||||||
refreshTransactions()
|
)
|
||||||
refreshAllBalances()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
when (result) {
|
|
||||||
SubmitResult.Success -> {
|
|
||||||
setStage(SendConfirmationStage.Confirmation)
|
|
||||||
goHome()
|
|
||||||
}
|
}
|
||||||
is SubmitResult.SimpleTrxFailure -> {
|
|
||||||
setStage(SendConfirmationStage.Failure(result.errorDescription))
|
|
||||||
}
|
|
||||||
is SubmitResult.MultipleTrxFailure -> {
|
|
||||||
setStage(SendConfirmationStage.MultipleTrxFailure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
walletRestoringState = walletRestoringState
|
walletRestoringState = walletRestoringState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (sendFundsAuthentication.value) {
|
||||||
|
activity.WrapAuthentication(
|
||||||
|
goSupport = {
|
||||||
|
sendFundsAuthentication.value = false
|
||||||
|
goSupport()
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
scope.launch {
|
||||||
|
runSendFundsAction(
|
||||||
|
createTransactionsViewModel = createTransactionsViewModel,
|
||||||
|
goHome = goHome,
|
||||||
|
// The not-null assertion operator is necessary here even if we check its
|
||||||
|
// nullability before due to property is declared in different module. See more
|
||||||
|
// details on the Kotlin forum
|
||||||
|
proposal = zecSend!!.proposal!!,
|
||||||
|
setStage = setStage,
|
||||||
|
spendingKey = spendingKey,
|
||||||
|
synchronizer = synchronizer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
sendFundsAuthentication.value = false
|
||||||
|
},
|
||||||
|
onFailed = {
|
||||||
|
sendFundsAuthentication.value = false
|
||||||
|
},
|
||||||
|
useCase = AuthenticationUseCase.SendFunds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
suspend fun runSendFundsAction(
|
||||||
|
createTransactionsViewModel: CreateTransactionsViewModel,
|
||||||
|
goHome: () -> Unit,
|
||||||
|
proposal: Proposal,
|
||||||
|
setStage: (SendConfirmationStage) -> Unit,
|
||||||
|
spendingKey: UnifiedSpendingKey,
|
||||||
|
synchronizer: Synchronizer,
|
||||||
|
) {
|
||||||
|
setStage(SendConfirmationStage.Sending)
|
||||||
|
|
||||||
|
val submitResult =
|
||||||
|
submitTransactions(
|
||||||
|
createTransactionsViewModel = createTransactionsViewModel,
|
||||||
|
proposal = proposal,
|
||||||
|
synchronizer = synchronizer,
|
||||||
|
spendingKey = spendingKey
|
||||||
|
)
|
||||||
|
|
||||||
|
Twig.debug { "Transactions submitted with result: $submitResult" }
|
||||||
|
|
||||||
|
processSubmissionResult(
|
||||||
|
goHome = goHome,
|
||||||
|
setStage = setStage,
|
||||||
|
submitResult = submitResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun submitTransactions(
|
||||||
|
createTransactionsViewModel: CreateTransactionsViewModel,
|
||||||
|
proposal: Proposal,
|
||||||
|
synchronizer: Synchronizer,
|
||||||
|
spendingKey: UnifiedSpendingKey
|
||||||
|
): SubmitResult {
|
||||||
|
Twig.debug { "Sending transactions..." }
|
||||||
|
|
||||||
|
val result =
|
||||||
|
createTransactionsViewModel.runCreateTransactions(
|
||||||
|
synchronizer = synchronizer,
|
||||||
|
spendingKey = spendingKey,
|
||||||
|
proposal = proposal
|
||||||
|
)
|
||||||
|
|
||||||
|
// Triggering the transaction history and balances refresh to be notified immediately
|
||||||
|
// about the wallet's updated state
|
||||||
|
(synchronizer as SdkSynchronizer).run {
|
||||||
|
refreshTransactions()
|
||||||
|
refreshAllBalances()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processSubmissionResult(
|
||||||
|
submitResult: SubmitResult,
|
||||||
|
setStage: (SendConfirmationStage) -> Unit,
|
||||||
|
goHome: () -> Unit
|
||||||
|
) {
|
||||||
|
when (submitResult) {
|
||||||
|
SubmitResult.Success -> {
|
||||||
|
setStage(SendConfirmationStage.Confirmation)
|
||||||
|
goHome()
|
||||||
|
}
|
||||||
|
is SubmitResult.SimpleTrxFailure -> {
|
||||||
|
setStage(SendConfirmationStage.Failure(submitResult.errorDescription))
|
||||||
|
}
|
||||||
|
is SubmitResult.MultipleTrxFailure -> {
|
||||||
|
setStage(SendConfirmationStage.MultipleTrxFailure)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,10 +39,6 @@ data class SendConfirmationArguments(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun hasValidZecSend() =
|
|
||||||
this.address != null &&
|
|
||||||
this.amount != null
|
|
||||||
|
|
||||||
internal fun toZecSend() =
|
internal fun toZecSend() =
|
||||||
ZecSend(
|
ZecSend(
|
||||||
destination = address?.toWalletAddress() ?: error("Address null"),
|
destination = address?.toWalletAddress() ?: error("Address null"),
|
||||||
|
|
|
@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
|
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
|
||||||
import cash.z.ecc.android.sdk.model.FirstClassByteArray
|
import cash.z.ecc.android.sdk.model.FirstClassByteArray
|
||||||
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
|
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
|
||||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
|
||||||
import cash.z.ecc.android.sdk.model.ZecSend
|
import cash.z.ecc.android.sdk.model.ZecSend
|
||||||
import cash.z.ecc.android.sdk.model.toZecString
|
import cash.z.ecc.android.sdk.model.toZecString
|
||||||
import cash.z.ecc.sdk.fixture.MemoFixture
|
import cash.z.ecc.sdk.fixture.MemoFixture
|
||||||
|
@ -119,12 +118,11 @@ private fun PreviewSendMultipleTransactionFailure() {
|
||||||
fun SendConfirmation(
|
fun SendConfirmation(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onContactSupport: () -> Unit,
|
onContactSupport: () -> Unit,
|
||||||
onCreateAndSend: (ZecSend) -> Unit,
|
onConfirmation: () -> Unit,
|
||||||
onStageChange: (SendConfirmationStage) -> Unit,
|
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
stage: SendConfirmationStage,
|
stage: SendConfirmationStage,
|
||||||
submissionResults: ImmutableList<TransactionSubmitResult>,
|
submissionResults: ImmutableList<TransactionSubmitResult>,
|
||||||
zecSend: ZecSend?,
|
zecSend: ZecSend,
|
||||||
walletRestoringState: WalletRestoringState,
|
walletRestoringState: WalletRestoringState,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
@ -140,8 +138,7 @@ fun SendConfirmation(
|
||||||
SendConfirmationMainContent(
|
SendConfirmationMainContent(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onContactSupport = onContactSupport,
|
onContactSupport = onContactSupport,
|
||||||
onSendSubmit = onCreateAndSend,
|
onConfirmation = onConfirmation,
|
||||||
onStageChange = onStageChange,
|
|
||||||
stage = stage,
|
stage = stage,
|
||||||
submissionResults = submissionResults,
|
submissionResults = submissionResults,
|
||||||
zecSend = zecSend,
|
zecSend = zecSend,
|
||||||
|
@ -213,25 +210,18 @@ private fun SendConfirmationTopAppBar(
|
||||||
private fun SendConfirmationMainContent(
|
private fun SendConfirmationMainContent(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onContactSupport: () -> Unit,
|
onContactSupport: () -> Unit,
|
||||||
onSendSubmit: (ZecSend) -> Unit,
|
onConfirmation: () -> Unit,
|
||||||
onStageChange: (SendConfirmationStage) -> Unit,
|
|
||||||
stage: SendConfirmationStage,
|
stage: SendConfirmationStage,
|
||||||
submissionResults: ImmutableList<TransactionSubmitResult>,
|
submissionResults: ImmutableList<TransactionSubmitResult>,
|
||||||
zecSend: ZecSend?,
|
zecSend: ZecSend,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (stage) {
|
when (stage) {
|
||||||
SendConfirmationStage.Confirmation, SendConfirmationStage.Sending, is SendConfirmationStage.Failure -> {
|
SendConfirmationStage.Confirmation, SendConfirmationStage.Sending, is SendConfirmationStage.Failure -> {
|
||||||
if (zecSend == null) {
|
|
||||||
error("Unexpected ZecSend value: $zecSend")
|
|
||||||
}
|
|
||||||
SendConfirmationContent(
|
SendConfirmationContent(
|
||||||
zecSend = zecSend,
|
zecSend = zecSend,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onConfirmation = {
|
onConfirmation = onConfirmation,
|
||||||
onStageChange(SendConfirmationStage.Sending)
|
|
||||||
onSendSubmit(zecSend)
|
|
||||||
},
|
|
||||||
isSending = stage == SendConfirmationStage.Sending,
|
isSending = stage == SendConfirmationStage.Sending,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
@ -252,8 +242,6 @@ private fun SendConfirmationMainContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val DEFAULT_LESS_THAN_FEE = 100_000L
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
private fun SendConfirmationContent(
|
private fun SendConfirmationContent(
|
||||||
|
@ -291,17 +279,10 @@ private fun SendConfirmationContent(
|
||||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
|
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
|
||||||
|
|
||||||
StyledBalance(
|
StyledBalance(
|
||||||
balanceString =
|
// The not-null assertion operator is necessary here even if we check its nullability before
|
||||||
if (zecSend.proposal == null) {
|
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
|
||||||
Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString()
|
// property declared in different module. See more details on the Kotlin forum.
|
||||||
} else {
|
balanceString = zecSend.proposal!!.totalFeeRequired().toZecString(),
|
||||||
// The not-null assertion operator is necessary here even if we check its nullability before
|
|
||||||
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
|
|
||||||
// property declared in different module
|
|
||||||
// See more details on the Kotlin forum
|
|
||||||
checkNotNull(zecSend.proposal)
|
|
||||||
zecSend.proposal!!.totalFeeRequired().toZecString()
|
|
||||||
},
|
|
||||||
textStyles =
|
textStyles =
|
||||||
Pair(
|
Pair(
|
||||||
ZcashTheme.extendedTypography.balanceSingleStyles.first,
|
ZcashTheme.extendedTypography.balanceSingleStyles.first,
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="authentication_system_ui_title">
|
||||||
|
Authentication for <xliff:g id="app_name" example="Zashi">%1$s</xliff:g>
|
||||||
|
</string>
|
||||||
|
<string name="authentication_system_ui_subtitle">
|
||||||
|
Use biometric or device credential to access <xliff:g id="use_case" example="Recovery Phrase">%1$s</xliff:g>.
|
||||||
|
</string>
|
||||||
|
|
||||||
|
<string name="authentication_use_case_delete_wallet">Delete Wallet feature</string>
|
||||||
|
<string name="authentication_use_case_export_data">Export Private Data feature</string>
|
||||||
|
<string name="authentication_use_case_seed_recovery">Seed Recovery feature</string>
|
||||||
|
<string name="authentication_use_case_send_funds">Send Funds feature</string>
|
||||||
|
|
||||||
|
<string name="authentication_toast_canceled">Authentication canceled</string>
|
||||||
|
<string name="authentication_toast_failed">Authentication failed</string>
|
||||||
|
|
||||||
|
<string name="authentication_error_title">Authentication error</string>
|
||||||
|
<string name="authentication_error_text">Authentication failed for the following reason. Retry the authentication, or contact the support team for help.</string>
|
||||||
|
<string name="authentication_error_details">
|
||||||
|
Error code: <xliff:g id="code" example="-1">%1$d</xliff:g>\nError message: <xliff:g id="message" example="No device credential">%2$s</xliff:g>
|
||||||
|
</string>
|
||||||
|
<string name="authentication_error_button_retry">Retry</string>
|
||||||
|
<string name="authentication_error_button_support">Contact Support</string>
|
||||||
|
|
||||||
|
<string name="authentication_failed_title">Authentication failed</string>
|
||||||
|
<string name="authentication_failed_text">
|
||||||
|
Authentication was presented but not recognized. Retry authentication, or contact the support team for help.
|
||||||
|
</string>
|
||||||
|
<string name="authentication_failed_button_retry">Retry</string>
|
||||||
|
<string name="authentication_failed_button_support">Contact Support</string>
|
||||||
|
</resources>
|
|
@ -5,6 +5,4 @@
|
||||||
|
|
||||||
<string name="onboarding_create_new_wallet">Create New Wallet</string>
|
<string name="onboarding_create_new_wallet">Create New Wallet</string>
|
||||||
<string name="onboarding_import_existing_wallet">Restore Existing Wallet</string>
|
<string name="onboarding_import_existing_wallet">Restore Existing Wallet</string>
|
||||||
|
|
||||||
<string name="zcash_logo_with_hi_text_content_description">Zcash logo with text Hi</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
package co.electroniccoin.zcash.ui.screenshot
|
package co.electroniccoin.zcash.ui.screenshot
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
// NOTE: this is just a placeholder test to satisfy this module test settings and will be removed once the below
|
||||||
|
// issue is resolved
|
||||||
|
class ScreenshotTest {
|
||||||
|
@Test
|
||||||
|
fun placeholderTest() {
|
||||||
|
assert(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
TODO [#1448]: Re-enable or rework screenshot testing
|
||||||
|
TODO [#1448]: https://github.com/Electric-Coin-Company/zashi-android/issues/1448
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
|
@ -537,3 +551,4 @@ private fun seedScreenshots(
|
||||||
|
|
||||||
ScreenshotTest.takeScreenshot(tag, "Seed 1")
|
ScreenshotTest.takeScreenshot(tag, "Seed 1")
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
Loading…
Reference in New Issue