* [#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:
parent
c7e5394940
commit
57a133a12c
|
@ -1,7 +1,11 @@
|
||||||
package co.electriccoin.zcash.ui.design.component
|
package co.electriccoin.zcash.ui.design.component
|
||||||
|
|
||||||
import android.graphics.BlurMaskFilter
|
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.border
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Paint
|
import androidx.compose.ui.graphics.Paint
|
||||||
import androidx.compose.ui.graphics.PaintingStyle
|
import androidx.compose.ui.graphics.PaintingStyle
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
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.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
@ -71,6 +82,10 @@ fun PrimaryButton(
|
||||||
blurRadius = 0.dp,
|
blurRadius = 0.dp,
|
||||||
stroke = textColor != MaterialTheme.colorScheme.primary,
|
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)
|
.defaultMinSize(ZcashTheme.dimens.defaultButtonWidth, ZcashTheme.dimens.defaultButtonHeight)
|
||||||
.border(1.dp, Color.Black),
|
.border(1.dp, Color.Black),
|
||||||
colors = buttonColors(
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,15 +2,17 @@ package co.electriccoin.zcash.ui.design.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
@ -18,24 +20,57 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
private fun ComposablePreview() {
|
private fun ComposablePreview() {
|
||||||
val checkBoxState = remember { mutableStateOf(false) }
|
val checkBoxState = remember { mutableStateOf(false) }
|
||||||
ZcashTheme(darkTheme = 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
|
@Composable
|
||||||
fun CheckBox(
|
fun CheckBox(
|
||||||
onCheckedChange: ((Boolean) -> Unit),
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
text: String,
|
text: String,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Checkbox(
|
||||||
checked = checked,
|
checked = checkedState,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = {
|
||||||
|
setCheckedState(it)
|
||||||
|
onCheckedChange(it)
|
||||||
|
},
|
||||||
enabled = true,
|
enabled = true,
|
||||||
modifier = modifier
|
modifier = checkBoxModifier
|
||||||
|
)
|
||||||
|
ClickableText(
|
||||||
|
onClick = {
|
||||||
|
setCheckedState(!checkedState)
|
||||||
|
onCheckedChange(!checkedState)
|
||||||
|
},
|
||||||
|
text = AnnotatedString(text),
|
||||||
|
style = ZcashTheme.extendedTypography.checkboxText
|
||||||
)
|
)
|
||||||
Text(text = text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.googlefonts.Font
|
import androidx.compose.ui.text.googlefonts.Font
|
||||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||||
import androidx.compose.ui.text.style.BaselineShift
|
import androidx.compose.ui.text.style.BaselineShift
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import co.electriccoin.zcash.ui.design.R
|
import co.electriccoin.zcash.ui.design.R
|
||||||
|
|
||||||
|
@ -91,6 +92,12 @@ internal val SecondaryTypography = Typography(
|
||||||
fontFamily = ArchivoFontFamily,
|
fontFamily = ArchivoFontFamily,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp
|
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 listItem: TextStyle,
|
||||||
val zecBalance: TextStyle,
|
val zecBalance: TextStyle,
|
||||||
val buttonText: TextStyle,
|
val buttonText: TextStyle,
|
||||||
|
val checkboxText: TextStyle,
|
||||||
|
val securityWarningText: TextStyle
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("CompositionLocalAllowlist")
|
@Suppress("CompositionLocalAllowlist")
|
||||||
|
@ -135,5 +144,11 @@ val LocalExtendedTypography = staticCompositionLocalOf {
|
||||||
buttonText = PrimaryTypography.bodySmall.copy(
|
buttonText = PrimaryTypography.bodySmall.copy(
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
),
|
),
|
||||||
|
checkboxText = PrimaryTypography.bodyMedium.copy(
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
securityWarningText = PrimaryTypography.bodySmall.copy(
|
||||||
|
lineHeight = 22.32.sp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,7 @@ android {
|
||||||
"src/main/res/ui/update",
|
"src/main/res/ui/update",
|
||||||
"src/main/res/ui/wallet_address",
|
"src/main/res/ui/wallet_address",
|
||||||
"src/main/res/ui/warning",
|
"src/main/res/ui/warning",
|
||||||
|
"src/main/res/ui/security_warning"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.NavHostController
|
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.common.BindCompLocalProvider
|
||||||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
||||||
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
|
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.component.Override
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
import co.electriccoin.zcash.ui.screen.backup.WrapNewWallet
|
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.HomeViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
|
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
|
||||||
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||||
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.securitywarning.WrapSecurityWarning
|
||||||
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
|
||||||
|
@ -128,10 +136,29 @@ class MainActivity : ComponentActivity() {
|
||||||
SecretState.None -> {
|
SecretState.None -> {
|
||||||
WrapOnboarding()
|
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 -> {
|
is SecretState.NeedsBackup -> {
|
||||||
WrapNewWallet(
|
WrapNewWallet(
|
||||||
secretState.persistableWallet,
|
secretState.persistableWallet,
|
||||||
onBackupComplete = { walletViewModel.persistBackupComplete() }
|
onBackupComplete = { walletViewModel.persistOnboardingState(OnboardingState.READY) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is SecretState.Ready -> {
|
is SecretState.Ready -> {
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
package co.electriccoin.zcash.ui.preference
|
package co.electriccoin.zcash.ui.preference
|
||||||
|
|
||||||
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
|
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.preference.model.entry.PreferenceKey
|
||||||
|
import co.electriccoin.zcash.ui.screen.home.model.OnboardingState
|
||||||
|
|
||||||
object StandardPreferenceKeys {
|
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
|
// Default to true until https://github.com/zcash/secant-android-wallet/issues/304
|
||||||
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(PreferenceKey("is_analytics_enabled"), true)
|
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(PreferenceKey("is_analytics_enabled"), true)
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
|
||||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
||||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
||||||
import co.electriccoin.zcash.ui.screen.history.state.TransactionHistorySyncState
|
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 co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.Dispatchers
|
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)
|
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
|
val secretState: StateFlow<SecretState> = combine(
|
||||||
.combine(isBackupComplete) { persistableWallet: PersistableWallet?, isBackupComplete: Boolean ->
|
walletCoordinator.persistableWallet,
|
||||||
if (null == persistableWallet) {
|
onboardingState
|
||||||
SecretState.None
|
) { persistableWallet: PersistableWallet?, onboardingState: OnboardingState ->
|
||||||
} else if (!isBackupComplete) {
|
when {
|
||||||
|
onboardingState == OnboardingState.NONE -> SecretState.None
|
||||||
|
onboardingState == OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
|
||||||
|
onboardingState == OnboardingState.NEEDS_BACKUP && persistableWallet != null -> {
|
||||||
SecretState.NeedsBackup(persistableWallet)
|
SecretState.NeedsBackup(persistableWallet)
|
||||||
} else {
|
}
|
||||||
|
onboardingState == OnboardingState.READY && persistableWallet != null -> {
|
||||||
SecretState.Ready(persistableWallet)
|
SecretState.Ready(persistableWallet)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
else -> SecretState.None
|
||||||
viewModelScope,
|
}
|
||||||
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
}.stateIn(
|
||||||
SecretState.Loading
|
viewModelScope,
|
||||||
)
|
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
||||||
|
SecretState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
// This needs to be refactored once we support pin lock
|
// This needs to be refactored once we support pin lock
|
||||||
val spendingKey = secretState
|
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
|
* is ready to use. Clients observe [secretState] to see the side effects. This would be used
|
||||||
* for a user creating a new wallet.
|
* for a user creating a new wallet.
|
||||||
*/
|
*/
|
||||||
fun persistBackupComplete() {
|
fun persistOnboardingState(onboardingState: OnboardingState) {
|
||||||
val application = getApplication<Application>()
|
val application = getApplication<Application>()
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
||||||
|
|
||||||
// Use the Mutex here to avoid timing issues. During wallet restore, persistBackupComplete()
|
// Use the Mutex here to avoid timing issues. During wallet restore, persistOnboardingState()
|
||||||
// is called prior to persistExistingWallet(). Although persistBackupComplete() should
|
// is called prior to persistExistingWallet(). Although persistOnboardingState() should
|
||||||
// complete quickly, it isn't guaranteed to complete before persistExistingWallet()
|
// complete quickly, it isn't guaranteed to complete before persistExistingWallet()
|
||||||
// unless a mutex is used here.
|
// unless a mutex is used here.
|
||||||
persistWalletMutex.withLock {
|
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 {
|
sealed class SecretState {
|
||||||
object Loading : SecretState()
|
object Loading : SecretState()
|
||||||
object None : SecretState()
|
object None : SecretState()
|
||||||
|
object NeedsWarning : SecretState()
|
||||||
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
|
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
|
||||||
class Ready(val persistableWallet: PersistableWallet) : SecretState()
|
class Ready(val persistableWallet: PersistableWallet) : SecretState()
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import co.electriccoin.zcash.ui.BuildConfig
|
||||||
import co.electriccoin.zcash.ui.MainActivity
|
import co.electriccoin.zcash.ui.MainActivity
|
||||||
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
|
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
|
||||||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
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.home.viewmodel.WalletViewModel
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.LongOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.LongOnboarding
|
||||||
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
import co.electriccoin.zcash.ui.screen.onboarding.view.ShortOnboarding
|
||||||
|
@ -51,20 +52,11 @@ internal fun WrapOnboarding(
|
||||||
!EmulatorWtfUtil.isEmulatorWtf(applicationContext)
|
!EmulatorWtfUtil.isEmulatorWtf(applicationContext)
|
||||||
|
|
||||||
// TODO [#383]: https://github.com/zcash/secant-android-wallet/issues/383
|
// 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) {
|
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
|
||||||
val onCreateWallet = {
|
val onCreateWallet = {
|
||||||
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) {
|
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
|
||||||
persistExistingWalletWithSeedPhrase(
|
|
||||||
applicationContext,
|
|
||||||
walletViewModel,
|
|
||||||
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
|
|
||||||
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
walletViewModel.persistNewWallet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
// Firebase Test Lab or Google Play pre-launch report, we want to skip creating
|
// Firebase Test Lab or Google Play pre-launch report, we want to skip creating
|
||||||
|
@ -130,7 +122,7 @@ internal fun persistExistingWalletWithSeedPhrase(
|
||||||
seedPhrase: SeedPhrase,
|
seedPhrase: SeedPhrase,
|
||||||
birthday: BlockHeight?
|
birthday: BlockHeight?
|
||||||
) {
|
) {
|
||||||
walletViewModel.persistBackupComplete()
|
walletViewModel.persistOnboardingState(OnboardingState.READY)
|
||||||
|
|
||||||
val network = ZcashNetwork.fromResources(context)
|
val network = ZcashNetwork.fromResources(context)
|
||||||
val restoredWallet = PersistableWallet(
|
val restoredWallet = PersistableWallet(
|
||||||
|
|
|
@ -71,7 +71,6 @@ private fun ComposablePreview() {
|
||||||
* @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
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
fun LongOnboarding(
|
fun LongOnboarding(
|
||||||
onboardingState: OnboardingState,
|
onboardingState: OnboardingState,
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -329,6 +329,7 @@ private fun onboardingScreenshots(
|
||||||
composeTestRule.activity.walletViewModel.secretState.value is SecretState.None
|
composeTestRule.activity.walletViewModel.secretState.value is SecretState.None
|
||||||
}
|
}
|
||||||
if (ConfigurationEntries.IS_SHORT_ONBOARDING_UX.getValue(emptyConfiguration)) {
|
if (ConfigurationEntries.IS_SHORT_ONBOARDING_UX.getValue(emptyConfiguration)) {
|
||||||
|
// Welcome screen
|
||||||
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_short_header)).also {
|
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_short_header)).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
ScreenshotTest.takeScreenshot(tag, "Onboarding 1")
|
ScreenshotTest.takeScreenshot(tag, "Onboarding 1")
|
||||||
|
@ -340,6 +341,19 @@ private fun onboardingScreenshots(
|
||||||
).also {
|
).also {
|
||||||
it.performClick()
|
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 {
|
} else {
|
||||||
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also {
|
composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_1_header)).also {
|
||||||
it.assertExists()
|
it.assertExists()
|
||||||
|
|
Loading…
Reference in New Issue