[#1407] Add Delete wallet feature

- Closes #1407
- Changelog update
- Link a new snapshot version of the Zcash SDK
This commit is contained in:
Honza Rychnovský 2024-05-02 10:07:28 +02:00 committed by GitHub
parent eae133f650
commit a1cf59f9b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 408 additions and 8 deletions

View File

@ -9,6 +9,10 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased]
### Added
- Delete Zashi feature has been added. It's accessible from the Advanced settings screen. It removes the wallet
secrets from Zashi and resets its state.
## [1.0 (638)] - 2024-04-26
### Fixed

View File

@ -198,7 +198,7 @@ ZXING_VERSION=3.5.3
ZCASH_BIP39_VERSION=1.0.8
# WARNING: Ensure a non-snapshot version is used before releasing to production
ZCASH_SDK_VERSION=2.1.1
ZCASH_SDK_VERSION=2.1.1-SNAPSHOT
# Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application.

View File

@ -14,4 +14,6 @@ interface PreferenceProvider {
suspend fun getString(key: PreferenceKey): String?
fun observe(key: PreferenceKey): Flow<String?>
suspend fun clearPreferences(): Boolean
}

View File

@ -18,6 +18,11 @@ class MockPreferenceProvider(
// For the mock implementation, does not support observability of changes
override fun observe(key: PreferenceKey): Flow<String?> = flow { emit(getString(key)) }
override suspend fun clearPreferences(): Boolean {
map.clear()
return true
}
override suspend fun hasKey(key: PreferenceKey) = map.containsKey(key.key)
override suspend fun putString(

View File

@ -59,6 +59,16 @@ class AndroidPreferenceProvider(
sharedPreferences.getString(key.key, null)
}
@SuppressLint("ApplySharedPref")
override suspend fun clearPreferences() =
withContext(dispatcher) {
val editor = sharedPreferences.edit()
editor.clear()
return@withContext editor.commit()
}
override fun observe(key: PreferenceKey): Flow<String?> =
callbackFlow<Unit> {
val listener =

View File

@ -182,7 +182,7 @@ data class ExtendedTypography(
val buttonTextSmall: TextStyle,
val checkboxText: TextStyle,
val securityWarningText: TextStyle,
val securityWarningFootnote: TextStyle,
val footnote: TextStyle,
val textFieldHint: TextStyle,
val textFieldValue: TextStyle,
val textFieldBirthday: TextStyle,
@ -192,6 +192,7 @@ data class ExtendedTypography(
// Grouping transaction item text styles to a wrapper class
val transactionItemStyles: TransactionItemTextStyles,
val restoringTopAppBarStyle: TextStyle,
val deleteWalletWarnStyle: TextStyle,
)
@Suppress("CompositionLocalAllowlist")
@ -271,7 +272,7 @@ val LocalExtendedTypography =
fontSize = 16.sp,
fontWeight = FontWeight.Medium
),
securityWarningFootnote =
footnote =
PrimaryTypography.bodySmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.Medium
@ -366,6 +367,10 @@ val LocalExtendedTypography =
SecondaryTypography.labelMedium.copy(
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
),
deleteWalletWarnStyle =
PrimaryTypography.bodyLarge.copy(
fontWeight = FontWeight.Bold
)
)
}

View File

@ -35,6 +35,7 @@ android {
"src/main/res/ui/account",
"src/main/res/ui/balances",
"src/main/res/ui/common",
"src/main/res/ui/delete_wallet",
"src/main/res/ui/export_data",
"src/main/res/ui/home",
"src/main/res/ui/choose_server",

View File

@ -18,6 +18,7 @@ import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.SCAN
@ -35,6 +36,7 @@ import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransiti
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
@ -136,6 +138,9 @@ internal fun MainActivity.Navigation() {
goChooseServer = {
navController.navigateJustOnce(CHOOSE_SERVER)
},
goDeleteWallet = {
navController.navigateJustOnce(DELETE_WALLET)
},
)
}
composable(CHOOSE_SERVER) {
@ -159,6 +164,9 @@ internal fun MainActivity.Navigation() {
// Pop back stack won't be right if we deep link into support
WrapSupport(goBack = { navController.popBackStackJustOnce(SUPPORT) })
}
composable(DELETE_WALLET) {
WrapDeleteWallet(goBack = { navController.popBackStackJustOnce(DELETE_WALLET) })
}
composable(ABOUT) {
WrapAbout(goBack = { navController.popBackStackJustOnce(ABOUT) })
}
@ -260,6 +268,7 @@ object NavigationArguments {
object NavigationTargets {
const val ABOUT = "about"
const val ADVANCED_SETTINGS = "advanced_settings"
const val DELETE_WALLET = "delete_wallet"
const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home"
const val CHOOSE_SERVER = "choose_server"

View File

@ -389,6 +389,63 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}
}
private fun clearAppStateFlow(): Flow<Boolean> =
callbackFlow {
val application = getApplication<Application>()
viewModelScope.launch {
val standardPrefsCleared =
StandardPreferenceSingleton
.getInstance(application)
.clearPreferences()
val encryptedPrefsCleared =
EncryptedPreferenceSingleton
.getInstance(application)
.clearPreferences()
Twig.info { "Both preferences cleared: ${standardPrefsCleared && encryptedPrefsCleared}" }
trySend(standardPrefsCleared && encryptedPrefsCleared)
}
awaitClose {
// Nothing to close here
}
}
fun deleteWalletFlow(): Flow<Boolean> =
callbackFlow {
Twig.info { "Delete wallet: Requested" }
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
(synchronizer as SdkSynchronizer).closeFlow().collect {
Twig.info { "Delete wallet: SDK closed" }
walletCoordinator.deleteSdkDataFlow().collect { isSdkErased ->
Twig.info { "Delete wallet: Erase SDK result: $isSdkErased" }
if (!isSdkErased) {
trySend(false)
}
clearAppStateFlow().collect { isAppErased ->
Twig.info { "Delete wallet: Erase SDK result: $isAppErased" }
if (!isAppErased) {
trySend(false)
} else {
trySend(true)
}
}
}
}
}
}
awaitClose {
// Nothing to close
}
}
}
/**

View File

@ -14,9 +14,10 @@ import co.electriccoin.zcash.ui.screen.advancedsettings.view.AdvancedSettings
@Composable
internal fun MainActivity.WrapAdvancedSettings(
goBack: () -> Unit,
goDeleteWallet: () -> Unit,
goExportPrivateData: () -> Unit,
goSeedRecovery: () -> Unit,
goChooseServer: () -> Unit,
goSeedRecovery: () -> Unit,
) {
val walletViewModel by viewModels<WalletViewModel>()
@ -24,19 +25,22 @@ internal fun MainActivity.WrapAdvancedSettings(
WrapAdvancedSettings(
goBack = goBack,
goDeleteWallet = goDeleteWallet,
goExportPrivateData = goExportPrivateData,
goChooseServer = goChooseServer,
goSeedRecovery = goSeedRecovery,
walletRestoringState = walletRestoringState
walletRestoringState = walletRestoringState,
)
}
@Composable
@Suppress("LongParameterList")
private fun WrapAdvancedSettings(
goBack: () -> Unit,
goExportPrivateData: () -> Unit,
goChooseServer: () -> Unit,
goSeedRecovery: () -> Unit,
goDeleteWallet: () -> Unit,
walletRestoringState: WalletRestoringState,
) {
BackHandler {
@ -45,9 +49,10 @@ private fun WrapAdvancedSettings(
AdvancedSettings(
onBack = goBack,
onSeedRecovery = goSeedRecovery,
onDeleteWallet = goDeleteWallet,
onExportPrivateData = goExportPrivateData,
onChooseServer = goChooseServer,
onSeedRecovery = goSeedRecovery,
walletRestoringState = walletRestoringState,
)
}

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.screen.advancedsettings.view
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
@ -9,14 +10,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
@ -34,6 +38,7 @@ private fun PreviewAdvancedSettings() {
GradientSurface {
AdvancedSettings(
onBack = {},
onDeleteWallet = {},
onExportPrivateData = {},
onChooseServer = {},
onSeedRecovery = {},
@ -44,8 +49,10 @@ private fun PreviewAdvancedSettings() {
}
@Composable
@Suppress("LongParameterList")
fun AdvancedSettings(
onBack: () -> Unit,
onDeleteWallet: () -> Unit,
onExportPrivateData: () -> Unit,
onChooseServer: () -> Unit,
onSeedRecovery: () -> Unit,
@ -69,6 +76,7 @@ fun AdvancedSettings(
start = dimens.screenHorizontalSpacingBig,
end = dimens.screenHorizontalSpacingBig
),
onDeleteWallet = onDeleteWallet,
onExportPrivateData = onExportPrivateData,
onSeedRecovery = onSeedRecovery,
onChooseServer = onChooseServer,
@ -98,9 +106,10 @@ private fun AdvancedSettingsTopAppBar(
@Composable
private fun AdvancedSettingsMainContent(
onSeedRecovery: () -> Unit,
onDeleteWallet: () -> Unit,
onExportPrivateData: () -> Unit,
onChooseServer: () -> Unit,
onSeedRecovery: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -131,6 +140,33 @@ private fun AdvancedSettingsMainContent(
modifier = Modifier.fillMaxWidth()
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
PrimaryButton(
onClick = onDeleteWallet,
text =
stringResource(
R.string.advanced_settings_delete_wallet,
stringResource(id = R.string.app_name)
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Text(
text = stringResource(id = R.string.advanced_settings_delete_wallet_footnote),
style = ZcashTheme.extendedTypography.footnote,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(dimens.spacingHuge))
}
}

View File

@ -0,0 +1,68 @@
package co.electriccoin.zcash.ui.screen.deletewallet
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.deletewallet.view.DeleteWallet
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapDeleteWallet(goBack: () -> Unit) {
val walletViewModel by viewModels<WalletViewModel>()
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
WrapDeleteWallet(
this,
goBack = goBack,
walletRestoringState = walletRestoringState,
walletViewModel = walletViewModel,
)
}
@Composable
internal fun WrapDeleteWallet(
context: Context,
goBack: () -> Unit,
walletRestoringState: WalletRestoringState,
walletViewModel: WalletViewModel,
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
BackHandler {
goBack()
}
DeleteWallet(
snackbarHostState = snackbarHostState,
onBack = goBack,
onConfirm = {
scope.launch {
walletViewModel.deleteWalletFlow().collect { isWalletDeleted ->
if (isWalletDeleted) {
Twig.info { "Wallet deleted successfully" }
// The app flows move to the Onboarding screens reactively
} else {
Twig.error { "Wallet deletion failed" }
snackbarHostState.showSnackbar(
message = context.getString(R.string.delete_wallet_failed)
)
}
}
}
},
walletRestoringState = walletRestoringState
)
}

View File

@ -0,0 +1,165 @@
package co.electriccoin.zcash.ui.screen.deletewallet.view
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.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
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.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TopScreenLogoTitle
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview("Delete Wallet")
@Composable
private fun ExportPrivateDataPreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
DeleteWallet(
snackbarHostState = SnackbarHostState(),
onBack = {},
onConfirm = {},
walletRestoringState = WalletRestoringState.NONE,
)
}
}
}
@Composable
fun DeleteWallet(
snackbarHostState: SnackbarHostState,
onBack: () -> Unit,
onConfirm: () -> Unit,
walletRestoringState: WalletRestoringState,
) {
Scaffold(
topBar = {
DeleteWalletDataTopAppBar(
onBack = onBack,
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
DeleteWalletContent(
onConfirm = onConfirm,
modifier =
Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.screenHorizontalSpacingBig,
end = ZcashTheme.dimens.screenHorizontalSpacingBig
)
.verticalScroll(rememberScrollState())
)
}
}
@Composable
private fun DeleteWalletDataTopAppBar(
onBack: () -> Unit,
showRestoring: Boolean
) {
SmallTopAppBar(
restoringLabel =
if (showRestoring) {
stringResource(id = R.string.restoring_wallet_label)
} else {
null
},
backText = stringResource(R.string.delete_wallet_back).uppercase(),
backContentDescriptionText = stringResource(R.string.delete_wallet_back_content_description),
onBack = onBack,
)
}
@Composable
private fun DeleteWalletContent(
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
val appName = stringResource(id = R.string.app_name)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
TopScreenLogoTitle(
title = stringResource(R.string.delete_wallet_title, appName),
logoContentDescription = stringResource(R.string.zcash_logo_content_description)
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingBig))
Text(
text = stringResource(R.string.delete_wallet_text_1),
style = ZcashTheme.extendedTypography.deleteWalletWarnStyle
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingUpLarge))
Body(
text =
stringResource(
R.string.delete_wallet_text_2,
appName
)
)
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
},
text = stringResource(R.string.delete_wallet_acknowledge),
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
PrimaryButton(
onClick = onConfirm,
text = stringResource(R.string.delete_wallet_button, appName).uppercase(),
enabled = checkedState.value,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingHuge))
}
}

View File

@ -167,7 +167,7 @@ fun SecurityWarningContentText(versionInfo: VersionInfo) {
}
append(textPart2)
},
style = ZcashTheme.extendedTypography.securityWarningFootnote,
style = ZcashTheme.extendedTypography.footnote,
modifier = Modifier.fillMaxWidth()
)
}

View File

@ -5,4 +5,10 @@
<string name="advanced_settings_backup_wallet">Recovery phrase</string>
<string name="advanced_settings_export_private_data">Export private data</string>
<string name="advanced_settings_choose_server">Choose a server</string>
<string name="advanced_settings_delete_wallet">
Delete <xliff:g id="app_name" example="Zashi">%1$s</xliff:g>
</string>
<string name="advanced_settings_delete_wallet_footnote">(You will be asked to confirm on next screen)</string>
</resources>

View File

@ -0,0 +1,27 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="delete_wallet_back">Back</string>
<string name="delete_wallet_back_content_description">Back</string>
<string name="delete_wallet_title">
Delete <xliff:g id="app_name" example="Zashi">%1$s</xliff:g>
</string>
<string name="delete_wallet_text_1">
Please don\'t delete this app unless you\'re sure you understand the effects.
</string>
<string name="delete_wallet_text_2">
Deleting the <xliff:g id="app_name" example="Zashi">%1$s</xliff:g> app will delete the database and cached
data. Any funds you have in this wallet will be lost and can only be recovered by using your <xliff:g
id="app_name" example="Zashi">%1$s</xliff:g> secret recovery phrase in <xliff:g id="app_name"
example="Zashi">%1$s</xliff:g> or another Zcash wallet.
</string>
<string name="delete_wallet_acknowledge">I understand</string>
<string name="delete_wallet_button">
Delete <xliff:g id="app_name" example="Zashi">%1$s</xliff:g>
</string>
<string name="delete_wallet_failed">Wallet deletion failed. Try it again, please.</string>
</resources>