[#296] About screen scaffold

This provides a basic scaffold for the About screen.  There are several followup issues:

 - #392 update the in-app legal text
 - #398 Add open source attributions
This commit is contained in:
Carter Jernigan 2022-04-25 08:35:30 -04:00 committed by Carter Jernigan
parent 5e1d25b34a
commit 08fad9e763
15 changed files with 426 additions and 51 deletions

View File

@ -192,6 +192,25 @@ class ScreenshotTest {
it.performClick()
}
composeTestRule.onNode(hasText(getStringResource(R.string.profile_title))).also {
it.assertExists()
it.performClick()
}
// About is a subscreen of profile
composeTestRule.onNode(hasText(getStringResource(R.string.profile_about))).also {
it.performScrollTo()
it.assertExists()
it.performClick()
}
aboutScreenshots(composeTestRule)
// Back to profile
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.about_back_content_description))).also {
it.assertExists()
it.performClick()
}
// Back to home
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
it.assertExists()
@ -431,3 +450,11 @@ private fun supportScreenshots(composeTestRule: ComposeTestRule) {
ScreenshotTest.takeScreenshot("Support 1")
}
private fun aboutScreenshots(composeTestRule: ComposeTestRule) {
composeTestRule.onNode(hasText(getStringResource(R.string.about_title))).also {
it.assertExists()
}
ScreenshotTest.takeScreenshot("About 1")
}

View File

@ -1,17 +1,16 @@
# Build Integrity
Multiple tools can be put in place to enhance build integrity and reduce the risk of supply chain issues. These tools include:
* Policy — We try to minimize third party dependencies, especially when they are not provided by Google and JetBrains. We also try to minimize the number of Gradle plugins.
* Policy — We minimize third party dependencies, especially when they are not provided by Google and JetBrains. We also try to minimize the number of Gradle plugins.
* Checklists — Our [pull request checklist](../.github/pull_request_template.md) specifies only running code from contributors after reviewing the changes first. Our [dependency update checklist](../.github/ISSUE_TEMPLATE/dependency.md) specifies verifying lock file changes during dependency updates.
* Fixed dependency versions — For our dependency declarations, we use exact dependency versions in gradle.properties instead of version ranges.
* GitHub Actions versions use SHA instead of tags
* Dependency locking
* Gradle buildscript (e.g. plugins) dependencies are locked
* Kotlin Multiplatform modules have dependency locking enabled
* Android modules do not have dependency locking for transitive dependencies enabled. [Issue #55](https://github.com/zcash/secant-android-wallet/issues/55) tracks this feature request.
* Dependency hash or signature verification
* Gradle — The SHA256 for Gradle is stored in [gradle/wrapper/gradle-wrapper.properties](../gradle/wrapper/gradle-wrapper.properties) which is verified when Gradle is downloaded for the first time
* Gradle Wrapper — The SHA256 for the Gradle Wrapper is verified on the continuous integration server
* Dependencies — Verification is not currently enabled for buildscript or compile dependencies
* Dependencies — Verification is NOT currently enabled for buildscript or compile dependencies
# Dependency locking
## Buildscript

View File

@ -30,6 +30,7 @@ android {
getByName("main").apply {
res.setSrcDirs(
setOf(
"src/main/res/ui/about",
"src/main/res/ui/backup",
"src/main/res/ui/common",
"src/main/res/ui/home",

View File

@ -0,0 +1,76 @@
package co.electriccoin.zcash.ui.screen.about
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.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.build.gitSha
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.VersionInfoFixture
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
import co.electriccoin.zcash.ui.screen.about.view.About
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class AboutViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun setup() {
newTestSetup()
composeTestRule.onNodeWithText(VersionInfoFixture.VERSION_NAME, substring = true).also {
it.assertExists()
}
composeTestRule.onNodeWithText(gitSha, substring = true).also {
it.assertExists()
}
}
@Test
@MediumTest
fun back() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.about_back_content_description)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
private fun newTestSetup() = TestSetup(composeTestRule, VersionInfoFixture.new())
private class TestSetup(private val composeTestRule: ComposeContentTestRule, versionInfo: VersionInfo) {
private val onBackCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
init {
composeTestRule.setContent {
ZcashTheme {
About(versionInfo = versionInfo) {
onBackCount.incrementAndGet()
}
}
}
}
}
}

View File

@ -11,11 +11,11 @@ import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.model.WalletAddress
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.profile.util.ProfileConfiguration
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@ -72,17 +72,18 @@ class ProfileViewTest {
@Test
@MediumTest
@Ignore("https://github.com/zcash/secant-android-wallet/issues/247")
fun address_book() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
if (ProfileConfiguration.IS_ADDRESS_BOOK_ENABLED) {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnAddressBookCount())
assertEquals(0, testSetup.getOnAddressBookCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_address_book)).also {
it.performClick()
composeTestRule.onNodeWithText(getStringResource(R.string.profile_address_book)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnAddressBookCount())
}
assertEquals(1, testSetup.getOnAddressBookCount())
}
@Test
@ -103,16 +104,18 @@ class ProfileViewTest {
@Test
@MediumTest
fun coinholder_vote() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
if (ProfileConfiguration.IS_COINHOLDER_VOTE_ENABLED) {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnCoinholderVoteCount())
assertEquals(0, testSetup.getOnCoinholderVoteCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_coinholder_vote)).also {
it.performScrollTo()
it.performClick()
composeTestRule.onNodeWithText(getStringResource(R.string.profile_coinholder_vote)).also {
it.performScrollTo()
it.performClick()
}
assertEquals(1, testSetup.getOnCoinholderVoteCount())
}
assertEquals(1, testSetup.getOnCoinholderVoteCount())
}
@Test
@ -131,6 +134,22 @@ class ProfileViewTest {
assertEquals(1, testSetup.getOnSupportCount())
}
@Test
@MediumTest
fun about() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnAboutCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_about)).also {
it.performScrollTo()
it.assertExists()
it.performClick()
}
assertEquals(1, testSetup.getOnAboutCount())
}
private fun newTestSetup(walletAddress: WalletAddress) = TestSetup(composeTestRule, walletAddress)
private class TestSetup(private val composeTestRule: ComposeContentTestRule, walletAddress: WalletAddress) {
@ -141,6 +160,7 @@ class ProfileViewTest {
private val onSettingsCount = AtomicInteger(0)
private val onCoinholderVoteCount = AtomicInteger(0)
private val onSupportCount = AtomicInteger(0)
private val onAboutCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
@ -172,6 +192,11 @@ class ProfileViewTest {
return onSupportCount.get()
}
fun getOnAboutCount(): Int {
composeTestRule.waitForIdle()
return onAboutCount.get()
}
init {
composeTestRule.setContent {
ZcashTheme {
@ -194,6 +219,9 @@ class ProfileViewTest {
},
onSupport = {
onSupportCount.getAndIncrement()
},
onAbout = {
onAboutCount.getAndIncrement()
}
)
}

View File

@ -30,6 +30,7 @@ import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.ui.design.compat.FontCompat
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.backup.WrapBackup
import co.electriccoin.zcash.ui.screen.backup.copyToClipboard
import co.electriccoin.zcash.ui.screen.home.model.spendableBalance
@ -38,7 +39,7 @@ 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.view.Onboarding
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.profile.view.Profile
import co.electriccoin.zcash.ui.screen.profile.WrapProfile
import co.electriccoin.zcash.ui.screen.request.view.Request
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
import co.electriccoin.zcash.ui.screen.restore.viewmodel.CompleteWordSetState
@ -195,6 +196,7 @@ class MainActivity : ComponentActivity() {
}
}
@Suppress("LongMethod")
@Composable
private fun Navigation() {
val navController = rememberNavController().also {
@ -217,7 +219,8 @@ class MainActivity : ComponentActivity() {
onAddressBook = { },
onSettings = { navController.navigate(NAV_SETTINGS) },
onCoinholderVote = { },
onSupport = { navController.navigate(NAV_SUPPORT) }
onSupport = { navController.navigate(NAV_SUPPORT) },
onAbout = { navController.navigate(NAV_ABOUT) }
)
}
composable(NAV_WALLET_ADDRESS_DETAILS) {
@ -254,6 +257,9 @@ class MainActivity : ComponentActivity() {
// Pop back stack won't be right if we deep link into support
WrapSupport(goBack = { navController.popBackStack() })
}
composable(NAV_ABOUT) {
WrapAbout(goBack = { navController.popBackStack() })
}
}
}
@ -281,32 +287,6 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
@Suppress("LongParameterList")
private fun WrapProfile(
onBack: () -> Unit,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit
) {
val walletAddresses = walletViewModel.addresses.collectAsState().value
if (null == walletAddresses) {
// Display loading indicator
} else {
Profile(
walletAddresses.unified,
onBack = onBack,
onAddressDetails = onAddressDetails,
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport
)
}
}
@Composable
private fun WrapWalletAddresses(
goBack: () -> Unit,
@ -446,6 +426,9 @@ class MainActivity : ComponentActivity() {
@VisibleForTesting
const val NAV_SUPPORT = "support"
@VisibleForTesting
const val NAV_ABOUT = "about"
}
}

View File

@ -0,0 +1,15 @@
package co.electriccoin.zcash.ui.fixture
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
// Magic Number doesn't matter here for hard-coded fixture values
@Suppress("MagicNumber")
object VersionInfoFixture {
const val VERSION_NAME = "1.0.3"
const val VERSION_CODE = 3L
fun new(
versionName: String = VERSION_NAME,
versionCode: Long = VERSION_CODE
) = VersionInfo(versionName, versionCode)
}

View File

@ -0,0 +1,24 @@
package co.electriccoin.zcash.ui.screen.about
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
import co.electriccoin.zcash.ui.screen.about.view.About
@Composable
internal fun MainActivity.WrapAbout(
goBack: () -> Unit
) {
WrapAbout(this, goBack)
}
@Composable
internal fun WrapAbout(
activity: ComponentActivity,
goBack: () -> Unit
) {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
About(VersionInfo.new(packageInfo), goBack)
}

View File

@ -0,0 +1,13 @@
package co.electriccoin.zcash.ui.screen.about.model
import android.content.pm.PackageInfo
import co.electriccoin.zcash.util.VersionCodeCompat
data class VersionInfo(val versionName: String, val versionCode: Long) {
companion object {
fun new(packageInfo: PackageInfo) = VersionInfo(
packageInfo.versionName ?: "null", // Should only be null during tests
VersionCodeCompat.getVersionCode(packageInfo)
)
}
}

View File

@ -0,0 +1,92 @@
package co.electriccoin.zcash.ui.screen.about.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.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.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.build.gitSha
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
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
@Composable
fun AboutPreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
About(versionInfo = VersionInfoFixture.new(), goBack = {})
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun About(
versionInfo: VersionInfo,
goBack: () -> Unit,
) {
Scaffold(topBar = {
AboutTopAppBar(onBack = goBack)
}) {
AboutMainContent(versionInfo)
}
}
@Composable
private fun AboutTopAppBar(onBack: () -> Unit) {
SmallTopAppBar(
title = { Text(text = stringResource(id = R.string.about_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.about_back_content_description)
)
}
}
)
}
@Composable
fun AboutMainContent(versionInfo: VersionInfo) {
Column(Modifier.verticalScroll(rememberScrollState())) {
Icon(painterResource(id = R.drawable.ic_launcher_adaptive_foreground), contentDescription = null)
Text(stringResource(id = R.string.app_name))
Spacer(modifier = Modifier.height(24.dp))
Header(stringResource(id = R.string.about_version_header))
Body(stringResource(R.string.about_version_format, versionInfo.versionName, versionInfo.versionCode))
Spacer(modifier = Modifier.height(24.dp))
Header(stringResource(id = R.string.about_build_header))
Body(gitSha)
Spacer(modifier = Modifier.height(24.dp))
Header(stringResource(id = R.string.about_legal_header))
Body(stringResource(id = R.string.about_legal_info))
}
}

View File

@ -0,0 +1,88 @@
package co.electriccoin.zcash.ui.screen.profile
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import cash.z.ecc.sdk.model.WalletAddresses
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.profile.view.Profile
@Composable
@Suppress("LongParameterList")
internal fun MainActivity.WrapProfile(
onBack: () -> Unit,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit,
onAbout: () -> Unit
) {
WrapProfile(
this,
onBack = onBack,
onAddressDetails = onAddressDetails,
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport,
onAbout = onAbout
)
}
@Composable
@Suppress("LongParameterList")
internal fun WrapProfile(
activity: ComponentActivity,
onBack: () -> Unit,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit,
onAbout: () -> Unit
) {
val viewModel by activity.viewModels<WalletViewModel>()
val walletAddresses = viewModel.addresses.collectAsState().value
WrapProfile(
walletAddresses,
onBack = onBack,
onAddressDetails = onAddressDetails,
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport,
onAbout = onAbout
)
}
@Composable
@Suppress("LongParameterList")
internal fun WrapProfile(
walletAddresses: WalletAddresses?,
onBack: () -> Unit,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit,
onAbout: () -> Unit
) {
if (null == walletAddresses) {
// Display loading indicator
} else {
Profile(
walletAddresses.unified,
onBack = onBack,
onAddressDetails = onAddressDetails,
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport,
onAbout = onAbout
)
}
}

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.ui.screen.profile.util
object ProfileConfiguration {
const val IS_ADDRESS_BOOK_ENABLED = false
const val IS_COINHOLDER_VOTE_ENABLED = false
}

View File

@ -31,6 +31,7 @@ import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.profile.util.AndroidQrCodeImageGenerator
import co.electriccoin.zcash.ui.screen.profile.util.JvmQrCodeGenerator
import co.electriccoin.zcash.ui.screen.profile.util.ProfileConfiguration
import kotlinx.coroutines.runBlocking
import kotlin.math.roundToInt
@ -46,7 +47,8 @@ fun ComposablePreview() {
onAddressBook = {},
onSettings = {},
onCoinholderVote = {},
onSupport = {}
onSupport = {},
onAbout = {}
)
}
}
@ -61,7 +63,8 @@ fun Profile(
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit
onSupport: () -> Unit,
onAbout: () -> Unit
) {
Column {
ProfileTopAppBar(onBack = onBack)
@ -71,7 +74,10 @@ fun Profile(
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport
onSupport = onSupport,
onAbout = onAbout,
isAddressBookEnabled = ProfileConfiguration.IS_ADDRESS_BOOK_ENABLED,
isCoinholderVoteEnabled = ProfileConfiguration.IS_COINHOLDER_VOTE_ENABLED
)
}
}
@ -104,7 +110,9 @@ private fun ProfileContents(
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit,
isAddressBookEnabled: Boolean = false
onAbout: () -> Unit,
isAddressBookEnabled: Boolean,
isCoinholderVoteEnabled: Boolean
) {
Column(Modifier.verticalScroll(rememberScrollState())) {
QrCode(data = walletAddress.address, DEFAULT_QR_CODE_SIZE, Modifier.align(Alignment.CenterHorizontally))
@ -123,8 +131,11 @@ private fun ProfileContents(
}
TertiaryButton(onClick = onSettings, text = stringResource(id = R.string.profile_settings))
Divider()
TertiaryButton(onClick = onCoinholderVote, text = stringResource(id = R.string.profile_coinholder_vote))
if (isCoinholderVoteEnabled) {
TertiaryButton(onClick = onCoinholderVote, text = stringResource(id = R.string.profile_coinholder_vote))
}
TertiaryButton(onClick = onSupport, text = stringResource(id = R.string.profile_support))
TertiaryButton(onClick = onAbout, text = stringResource(id = R.string.profile_about))
}
}

View File

@ -0,0 +1,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="about_title">About</string>
<string name="about_back_content_description">Back</string>
<string name="about_icon_content_description">Zcash icon</string>
<string name="about_version_header">Version</string>
<string name="about_version_format" formatted="true"><xliff:g id="version_name" example="1.0">%1$s</xliff:g> (<xliff:g id="version_code" example="1.0">%2$d</xliff:g>)</string>
<string name="about_build_header">Build</string>
<string name="about_legal_header">Legal</string>
<!-- TODO [#392] Update with real legal info. -->
<string name="about_legal_info">GitHub Issue #392</string>
</resources>

View File

@ -9,5 +9,6 @@
<string name="profile_settings">Settings</string>
<string name="profile_coinholder_vote">Coinholder Vote</string>
<string name="profile_support">Support</string>
<string name="profile_about">About</string>
</resources>