[#946][#993] Warning screen + button animation (#994)

* [#946] Warning screen UI

[#946] Code review changes
moved security warning to separate folder
changed main heading text style
changed static version text to dynamic

Co-authored-by: Honza Rychnovský <rychnovsky.honza@gmail.com>

* [#993] New Primary Button Click Animation

* [#946] Warning Screen [UI][Logic][Tests]

- Security Warning screen incorporated into the app onboarding logic
- NeedsWarning wallet state persisted and is required if the user re-runs next time, and the Security Warning screen still needs to be presented
- Privacy Policy opened by part of the screen text clicking in an external web browser app
- Unit tests
- UI tests
- Screenshot tests

---------

Co-authored-by: Venkat-corebts <143575548+Venkat-corebts@users.noreply.github.com>
This commit is contained in:
Honza Rychnovský 2023-10-10 12:41:17 +02:00 committed by GitHub
parent c7e5394940
commit 57a133a12c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 796 additions and 44 deletions

View File

@ -1,7 +1,11 @@
package co.electriccoin.zcash.ui.design.component
import android.graphics.BlurMaskFilter
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
@ -13,14 +17,21 @@ import androidx.compose.material3.ButtonDefaults.buttonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@ -71,6 +82,10 @@ fun PrimaryButton(
blurRadius = 0.dp,
stroke = textColor != MaterialTheme.colorScheme.primary,
)
.translationClick(
translationX = ZcashTheme.dimens.shadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.shadowOffsetX + 6.dp
)
.defaultMinSize(ZcashTheme.dimens.defaultButtonWidth, ZcashTheme.dimens.defaultButtonHeight)
.border(1.dp, Color.Black),
colors = buttonColors(
@ -253,3 +268,51 @@ fun Modifier.shadow(
}
}
)
private enum class ButtonState { Pressed, Idle }
fun Modifier.translationClick(
translationX: Dp = 0.dp,
translationY: Dp = 0.dp
) = composed {
var buttonState by remember { mutableStateOf(ButtonState.Idle) }
val translationXAnimated by animateFloatAsState(
targetValue = if (buttonState == ButtonState.Pressed) {
translationX.value
} else {
0f
},
label = "ClickTranslationXAnimation",
animationSpec = tween(
durationMillis = 100
)
)
val translationYAnimated by animateFloatAsState(
targetValue = if (buttonState == ButtonState.Pressed) {
translationY.value
} else {
0f
},
label = "ClickTranslationYAnimation",
animationSpec = tween(
durationMillis = 100
)
)
this
.graphicsLayer {
this.translationX = translationXAnimated
this.translationY = translationYAnimated
}
.pointerInput(buttonState) {
awaitPointerEventScope {
buttonState = if (buttonState == ButtonState.Pressed) {
waitForUpOrCancellation()
ButtonState.Idle
} else {
awaitFirstDown(false)
ButtonState.Pressed
}
}
}
}

View File

@ -2,15 +2,17 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview
@ -18,24 +20,57 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
private fun ComposablePreview() {
val checkBoxState = remember { mutableStateOf(false) }
ZcashTheme(darkTheme = false) {
CheckBox(text = "test", onCheckedChange = { checkBoxState.value = it }, checked = checkBoxState.value)
CheckBox(
onCheckedChange = { checkBoxState.value = it },
text = "test",
checked = checkBoxState.value,
checkBoxTestTag = null
)
}
}
@Composable
fun CheckBox(
onCheckedChange: ((Boolean) -> Unit),
onCheckedChange: (Boolean) -> Unit,
text: String,
modifier: Modifier = Modifier,
checked: Boolean = false
checked: Boolean = false,
checkBoxTestTag: String? = null
) {
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
val checkBoxModifier = Modifier
.padding(
top = ZcashTheme.dimens.spacingTiny,
bottom = ZcashTheme.dimens.spacingTiny,
end = ZcashTheme.dimens.spacingTiny
)
.then(
if (checkBoxTestTag != null) {
Modifier.testTag(checkBoxTestTag)
} else {
Modifier
}
)
val (checkedState, setCheckedState) = rememberSaveable { mutableStateOf(checked) }
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
checked = checkedState,
onCheckedChange = {
setCheckedState(it)
onCheckedChange(it)
},
enabled = true,
modifier = modifier
modifier = checkBoxModifier
)
ClickableText(
onClick = {
setCheckedState(!checkedState)
onCheckedChange(!checkedState)
},
text = AnnotatedString(text),
style = ZcashTheme.extendedTypography.checkboxText
)
Text(text = text)
}
}

View File

@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
import co.electriccoin.zcash.ui.design.R
@ -91,6 +92,12 @@ internal val SecondaryTypography = Typography(
fontFamily = ArchivoFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
headlineMedium = TextStyle(
fontFamily = ArchivoFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 25.sp,
textAlign = TextAlign.Center
)
)
@ -106,6 +113,8 @@ data class ExtendedTypography(
val listItem: TextStyle,
val zecBalance: TextStyle,
val buttonText: TextStyle,
val checkboxText: TextStyle,
val securityWarningText: TextStyle
)
@Suppress("CompositionLocalAllowlist")
@ -135,5 +144,11 @@ val LocalExtendedTypography = staticCompositionLocalOf {
buttonText = PrimaryTypography.bodySmall.copy(
fontSize = 14.sp
),
checkboxText = PrimaryTypography.bodyMedium.copy(
fontSize = 14.sp
),
securityWarningText = PrimaryTypography.bodySmall.copy(
lineHeight = 22.32.sp
)
)
}

View File

@ -44,6 +44,7 @@ android {
"src/main/res/ui/update",
"src/main/res/ui/wallet_address",
"src/main/res/ui/warning",
"src/main/res/ui/security_warning"
)
)
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.screen.securitywarning.util
import android.content.Intent
import androidx.test.filters.SmallTest
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.test.assertContains
class WebBrowserUtilTest {
@Test
@SmallTest
fun check_intent_for_web_browser() {
val intent = WebBrowserUtil.newActivityIntent(WebBrowserUtil.ZCASH_PRIVACY_POLICY_URI)
assertEquals(intent.action, Intent.ACTION_VIEW)
assertEquals(WebBrowserUtil.FLAGS, intent.flags)
assertContains(WebBrowserUtil.ZCASH_PRIVACY_POLICY_URI, intent.data.toString())
}
}

View File

@ -0,0 +1,120 @@
package co.electriccoin.zcash.ui.screen.securitywarning.view
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Rule
import kotlin.test.Test
import kotlin.test.assertEquals
class SecurityWarningViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun default_ui_state_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
assertEquals(false, testSetup.getOnAcknowledged())
assertEquals(0, testSetup.getOnConfirmCount())
composeTestRule.onNodeWithTag(SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG).also {
it.assertExists()
it.assertIsDisplayed()
it.assertHasClickAction()
it.assertIsEnabled()
}
composeTestRule.onNodeWithText(getStringResource(R.string.security_warning_confirm), ignoreCase = true).also {
it.assertExists()
it.assertIsDisplayed()
it.assertHasClickAction()
it.assertIsNotEnabled()
}
composeTestRule.onNodeWithTag(SecurityScreenTag.WARNING_TEXT_TAG).also {
it.assertExists()
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun back_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.clickBack()
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun click_disabled_confirm_button_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnConfirmCount())
assertEquals(false, testSetup.getOnAcknowledged())
composeTestRule.clickConfirm()
assertEquals(0, testSetup.getOnConfirmCount())
assertEquals(false, testSetup.getOnAcknowledged())
}
@Test
@MediumTest
fun click_enabled_confirm_button_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnConfirmCount())
assertEquals(false, testSetup.getOnAcknowledged())
composeTestRule.clickAcknowledge()
assertEquals(0, testSetup.getOnConfirmCount())
assertEquals(true, testSetup.getOnAcknowledged())
composeTestRule.clickConfirm()
assertEquals(1, testSetup.getOnConfirmCount())
assertEquals(true, testSetup.getOnAcknowledged())
}
private fun newTestSetup() = SecurityWarningViewTestSetup(composeTestRule).apply {
setDefaultContent()
}
}
private fun ComposeContentTestRule.clickBack() {
onNodeWithContentDescription(getStringResource(R.string.support_back_content_description)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickConfirm() {
onNodeWithText(getStringResource(R.string.security_warning_confirm), ignoreCase = true).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickAcknowledge() {
onNodeWithText(getStringResource(R.string.security_warning_acknowledge)).also {
it.performClick()
}
}

View File

@ -0,0 +1,66 @@
package co.electriccoin.zcash.ui.screen.securitywarning.view
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.spackle.getPackageInfoCompat
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
import co.electriccoin.zcash.ui.test.getAppContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class SecurityWarningViewTestSetup(private val composeTestRule: ComposeContentTestRule) {
private val onBackCount = AtomicInteger(0)
private val onAcknowledged = AtomicBoolean(false)
private val onConfirmCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
fun getOnAcknowledged(): Boolean {
composeTestRule.waitForIdle()
return onAcknowledged.get()
}
fun getOnConfirmCount(): Int {
composeTestRule.waitForIdle()
return onConfirmCount.get()
}
@Composable
@Suppress("TestFunctionName")
fun DefaultContent() {
SecurityWarning(
SnackbarHostState(),
onBack = {
onBackCount.incrementAndGet()
},
onPrivacyPolicy = {
// Not tested yet. UI testing of clicking on an AnnotatedString Text part is complicated.
},
onAcknowledged = {
onAcknowledged.getAndSet(it)
},
onConfirm = {
onConfirmCount.incrementAndGet()
},
versionInfo = VersionInfo.new(
getAppContext().packageManager.getPackageInfoCompat(getAppContext().packageName, 0L)
)
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
DefaultContent()
}
}
}
}

View File

@ -18,6 +18,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavHostController
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.ui.common.BindCompLocalProvider
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
@ -25,10 +30,13 @@ import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Override
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.backup.WrapNewWallet
import co.electriccoin.zcash.ui.screen.home.model.OnboardingState
import co.electriccoin.zcash.ui.screen.home.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning
import co.electriccoin.zcash.ui.screen.warning.WrapNotEnoughSpace
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
import co.electriccoin.zcash.work.WorkIds
@ -128,10 +136,29 @@ class MainActivity : ComponentActivity() {
SecretState.None -> {
WrapOnboarding()
}
is SecretState.NeedsWarning -> {
WrapSecurityWarning(
onBack = { walletViewModel.persistOnboardingState(OnboardingState.NONE) },
onConfirm = {
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_BACKUP)
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) {
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
} else {
walletViewModel.persistNewWallet()
}
}
)
}
is SecretState.NeedsBackup -> {
WrapNewWallet(
secretState.persistableWallet,
onBackupComplete = { walletViewModel.persistBackupComplete() }
onBackupComplete = { walletViewModel.persistOnboardingState(OnboardingState.READY) }
)
}
is SecretState.Ready -> {

View File

@ -1,14 +1,19 @@
package co.electriccoin.zcash.ui.preference
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.preference.model.entry.IntegerPreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import co.electriccoin.zcash.ui.screen.home.model.OnboardingState
object StandardPreferenceKeys {
/**
* Whether the user has completed the backup flow for a newly created wallet.
* State defining whether the user has completed any of the onboarding wallet states.
*/
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(PreferenceKey("is_user_backup_complete"), false)
val ONBOARDING_STATE = IntegerPreferenceDefault(
PreferenceKey("onboarding_state"),
OnboardingState.NONE.toNumber()
)
// Default to true until https://github.com/zcash/secant-android-wallet/issues/304
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(PreferenceKey("is_analytics_enabled"), true)

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.screen.home.model
/**
* Common Onboarding/SecurityWarning/Backup screen enum.
*
* WARN: Do NOT re-order or change the values, it would update their ordinal numbers, and thus break screens ordering.
*/
enum class OnboardingState {
NONE,
NEEDS_WARN,
NEEDS_BACKUP,
READY;
fun toNumber() = ordinal
companion object {
fun fromNumber(ordinal: Int) = entries[ordinal]
}
}

View File

@ -32,6 +32,7 @@ import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
import co.electriccoin.zcash.ui.screen.home.model.OnboardingState
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
@ -93,27 +94,37 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
)
/**
* A flow of whether a backup of the user's wallet has been performed.
* A flow of the wallet onboarding state.
*/
private val isBackupComplete = flow {
private val onboardingState = flow {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.observe(preferenceProvider))
emitAll(
StandardPreferenceKeys.ONBOARDING_STATE.observe(preferenceProvider).map { persistedNumber ->
OnboardingState.fromNumber(persistedNumber)
}
)
}
val secretState: StateFlow<SecretState> = walletCoordinator.persistableWallet
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
if (null == persistableWallet) {
SecretState.None
} else if (!isBackupComplete) {
val secretState: StateFlow<SecretState> = combine(
walletCoordinator.persistableWallet,
onboardingState
) { persistableWallet: PersistableWallet?, onboardingState: OnboardingState ->
when {
onboardingState == OnboardingState.NONE -> SecretState.None
onboardingState == OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
onboardingState == OnboardingState.NEEDS_BACKUP && persistableWallet != null -> {
SecretState.NeedsBackup(persistableWallet)
} else {
}
onboardingState == OnboardingState.READY && persistableWallet != null -> {
SecretState.Ready(persistableWallet)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
else -> SecretState.None
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
// This needs to be refactored once we support pin lock
val spendingKey = secretState
@ -229,18 +240,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
* is ready to use. Clients observe [secretState] to see the side effects. This would be used
* for a user creating a new wallet.
*/
fun persistBackupComplete() {
fun persistOnboardingState(onboardingState: OnboardingState) {
val application = getApplication<Application>()
viewModelScope.launch {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
// Use the Mutex here to avoid timing issues. During wallet restore, persistBackupComplete()
// is called prior to persistExistingWallet(). Although persistBackupComplete() should
// Use the Mutex here to avoid timing issues. During wallet restore, persistOnboardingState()
// is called prior to persistExistingWallet(). Although persistOnboardingState() should
// complete quickly, it isn't guaranteed to complete before persistExistingWallet()
// unless a mutex is used here.
persistWalletMutex.withLock {
StandardPreferenceKeys.IS_USER_BACKUP_COMPLETE.putValue(preferenceProvider, true)
StandardPreferenceKeys.ONBOARDING_STATE.putValue(preferenceProvider, onboardingState.toNumber())
}
}
}
@ -270,6 +281,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
sealed class SecretState {
object Loading : SecretState()
object None : SecretState()
object NeedsWarning : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}

View File

@ -23,6 +23,7 @@ import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.screen.home.model.OnboardingState
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.view.LongOnboarding
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
@ -51,20 +52,11 @@ internal fun WrapOnboarding(
!EmulatorWtfUtil.isEmulatorWtf(applicationContext)
// TODO [#383]: https://github.com/zcash/secant-android-wallet/issues/383
// TODO [#383]: Refactoring of UI state retention into rememberSaveable fields
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
val onCreateWallet = {
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) {
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
} else {
walletViewModel.persistNewWallet()
}
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
}
val onImportWallet = {
// In the case of the app currently being messed with by the robo test runner on
// Firebase Test Lab or Google Play pre-launch report, we want to skip creating
@ -130,7 +122,7 @@ internal fun persistExistingWalletWithSeedPhrase(
seedPhrase: SeedPhrase,
birthday: BlockHeight?
) {
walletViewModel.persistBackupComplete()
walletViewModel.persistOnboardingState(OnboardingState.READY)
val network = ZcashNetwork.fromResources(context)
val restoredWallet = PersistableWallet(

View File

@ -71,7 +71,6 @@ private fun ComposablePreview() {
* @param onCreateWallet Callback when the user decides to create a new wallet.
*/
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongParameterList")
fun LongOnboarding(
onboardingState: OnboardingState,

View File

@ -0,0 +1,80 @@
package co.electriccoin.zcash.ui.screen.securitywarning
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import co.electriccoin.zcash.configuration.AndroidConfigurationFactory
import co.electriccoin.zcash.spackle.getPackageInfoCompat
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
import co.electriccoin.zcash.ui.screen.securitywarning.util.WebBrowserUtil
import co.electriccoin.zcash.ui.screen.securitywarning.view.SecurityWarning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapSecurityWarning(
onBack: () -> Unit,
onConfirm: () -> Unit
) {
WrapSecurityWarning(
this,
onBack = onBack,
onConfirm = onConfirm
)
}
@Composable
internal fun WrapSecurityWarning(
activity: ComponentActivity,
onBack: () -> Unit,
onConfirm: () -> Unit
) {
val packageInfo = activity.packageManager.getPackageInfoCompat(activity.packageName, 0L)
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
SecurityWarning(
snackbarHostState = snackbarHostState,
versionInfo = VersionInfo.new(packageInfo),
onBack = onBack,
onAcknowledged = {
// Needed for UI testing only
},
onPrivacyPolicy = {
openPrivacyPolicyInWebBrowser(
activity.applicationContext,
snackbarHostState,
scope
)
},
onConfirm = onConfirm
)
LaunchedEffect(key1 = true) {
AndroidConfigurationFactory.getInstance(activity.applicationContext).hintToRefresh()
}
}
fun openPrivacyPolicyInWebBrowser(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val storeIntent = WebBrowserUtil.newActivityIntent(WebBrowserUtil.ZCASH_PRIVACY_POLICY_URI)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.security_warning_unable_to_web_browser)
)
}
}
}

View File

@ -0,0 +1,30 @@
package co.electriccoin.zcash.ui.screen.securitywarning.util
import android.content.Intent
import android.net.Uri
object WebBrowserUtil {
const val FLAGS = Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
const val ZCASH_PRIVACY_POLICY_URI = "https://z.cash/privacy-policy/"
/**
* Returns new action view app intent. We assume the a web browser app is installed.
*
* @param url The webpage url to open
*
* @return Intent for launching in a browser app.
*/
internal fun newActivityIntent(url: String): Intent {
val storeUri = Uri.parse(url)
val storeIntent = Intent(Intent.ACTION_VIEW, storeUri)
// To properly handle the browser backstack while navigate back to our app
storeIntent.addFlags(FLAGS)
return storeIntent
}
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.screen.securitywarning.view
/**
* These are only used for automated testing.
*/
object SecurityScreenTag {
const val ACKNOWLEDGE_CHECKBOX_TAG = "acknowledge_checkbox"
const val WARNING_TEXT_TAG = "warning_text"
}

View File

@ -0,0 +1,216 @@
package co.electriccoin.zcash.ui.screen.securitywarning.view
import androidx.compose.foundation.Image
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.CheckBox
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.VersionInfoFixture
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
@Preview("Security Warning")
@Composable
private fun SecurityWarningPreview() {
ZcashTheme {
GradientSurface {
SecurityWarning(
snackbarHostState = SnackbarHostState(),
versionInfo = VersionInfoFixture.new(),
onBack = {},
onAcknowledged = {},
onPrivacyPolicy = {},
onConfirm = {},
)
}
}
}
@Composable
@Suppress("LongParameterList")
fun SecurityWarning(
snackbarHostState: SnackbarHostState,
versionInfo: VersionInfo,
onBack: () -> Unit,
onAcknowledged: (Boolean) -> Unit,
onPrivacyPolicy: () -> Unit,
onConfirm: () -> Unit,
) {
Scaffold(
topBar = { SecurityWarningTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
SecurityWarningContent(
versionInfo = versionInfo,
onPrivacyPolicy = onPrivacyPolicy,
onAcknowledged = onAcknowledged,
onConfirm = onConfirm,
modifier = Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.spacingHuge,
end = ZcashTheme.dimens.spacingHuge
)
.verticalScroll(rememberScrollState())
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SecurityWarningTopAppBar(
onBack: () -> Unit,
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.security_warning_back).uppercase(),
style = ZcashTheme.typography.primary.bodyMedium
)
},
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.security_warning_back_content_description)
)
}
}
)
}
@Composable
private fun SecurityWarningContent(
versionInfo: VersionInfo,
onPrivacyPolicy: () -> Unit,
onAcknowledged: (Boolean) -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painterResource(id = R.drawable.zashi_logo_without_text),
stringResource(R.string.zcash_logo_content_description),
Modifier.fillMaxWidth()
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingXlarge))
Text(
text = stringResource(R.string.security_warning_header),
style = ZcashTheme.typography.secondary.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
SecurityWarningContentText(
versionInfo = versionInfo,
onPrivacyPolicy = onPrivacyPolicy
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
val checkedState = rememberSaveable { mutableStateOf(false) }
CheckBox(
modifier = Modifier
.align(Alignment.Start)
.fillMaxWidth(),
checked = checkedState.value,
onCheckedChange = {
checkedState.value = it
onAcknowledged(it)
},
text = stringResource(R.string.security_warning_acknowledge),
checkBoxTestTag = SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG
)
Spacer(
modifier = Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
PrimaryButton(
onClick = onConfirm,
text = stringResource(R.string.security_warning_confirm).uppercase(),
enabled = checkedState.value
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingXlarge))
}
}
@Composable
fun SecurityWarningContentText(
versionInfo: VersionInfo,
onPrivacyPolicy: () -> Unit,
) {
val textPart1 = stringResource(R.string.security_warning_text_part_1, versionInfo.versionName)
val textPart2 = stringResource(R.string.security_warning_text_part_2)
ClickableText(
text = buildAnnotatedString {
append(textPart1)
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
append(textPart2)
}
append(stringResource(R.string.security_warning_text_part_3))
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.security_warning_text_part_4))
}
append(stringResource(R.string.security_warning_text_part_5))
},
style = ZcashTheme.extendedTypography.securityWarningText,
modifier = Modifier
.fillMaxWidth()
.testTag(SecurityScreenTag.WARNING_TEXT_TAG),
onClick = { letterOffset ->
// Call the callback only if user clicked the underlined part
if (letterOffset >= textPart1.length && letterOffset <= (textPart1.length + textPart2.length)) {
onPrivacyPolicy()
}
}
)
}

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="47dp"
android:viewportWidth="36"
android:viewportHeight="47">
<path
android:strokeWidth="1"
android:pathData="M19.331,33.909L24.858,45L10.45,38.564L35,25.787L33.016,18.781L2,2L22.511,27.926L33.016,18.781L10.45,38.564"
android:fillColor="#00000000"
android:strokeColor="#0A141F"/>
</vector>

View File

@ -0,0 +1,19 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="security_warning_text_part_1" formatted="true">Zashi <xliff:g example="0.2.0" id="version_name">%1$s
</xliff:g>is a Zcash-only shielded wallet, built by Zcashers for Zcashers. The purpose of this release is
primarily to test functionality and collect feedback. While Zashi has been engineered for your privacy and
safety (read the privacy policy\u0020</string>
<string name="security_warning_text_part_2">here</string>
<string name="security_warning_text_part_3">), this release has not yet been security audited.</string>
<string name="security_warning_text_part_4">\u0020Users are cautioned to deposit, send, and receive only small
amounts
of ZEC.</string>
<string name="security_warning_text_part_5">\u0020Please click below to proceed.</string>
<string name="security_warning_confirm">confirm</string>
<string name="security_warning_acknowledge">I acknowledge</string>
<string name="security_warning_back_content_description">Back</string>
<string name="zcash_logo_content_description">Zcash logo</string>
<string name="security_warning_header">Security warning:</string>
<string name="security_warning_back">back</string>
<string name="security_warning_unable_to_web_browser">Unable to find a web browser app.</string>
</resources>

View File

@ -329,6 +329,7 @@ private fun onboardingScreenshots(
composeTestRule.activity.walletViewModel.secretState.value is SecretState.None
}
if (ConfigurationEntries.IS_SHORT_ONBOARDING_UX.getValue(emptyConfiguration)) {
// Welcome screen
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_short_header)).also {
it.assertExists()
ScreenshotTest.takeScreenshot(tag, "Onboarding 1")
@ -340,6 +341,19 @@ private fun onboardingScreenshots(
).also {
it.performClick()
}
// Security Warning screen
composeTestRule.onNodeWithText(text = resContext.getString(R.string.security_warning_acknowledge)).also {
it.assertExists()
it.performClick()
ScreenshotTest.takeScreenshot(tag, "Security Warning")
}
composeTestRule.onNodeWithText(
text = resContext.getString(R.string.security_warning_confirm),
ignoreCase = true
).also {
it.performClick()
}
} else {
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also {
it.assertExists()