[#145] Profile scaffold

This commit is contained in:
Carter Jernigan 2022-01-13 12:49:08 -05:00 committed by GitHub
parent 35055c3cfe
commit d46cf8187c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 605 additions and 99 deletions

View File

@ -0,0 +1,5 @@
# QR Code Contents
1. Configure a wallet in the Zcash app
1. Visit the profile screen
1. Using another device, scan the QR code
1. Verify the QR code contents match the QA address displayed below the QR code

View File

@ -88,6 +88,7 @@ KOTLINX_COROUTINES_VERSION=1.6.0
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZCASH_BIP39_VERSION=1.0.2 ZCASH_BIP39_VERSION=1.0.2
ZCASH_SDK_VERSION=1.3.0-beta19 ZCASH_SDK_VERSION=1.3.0-beta19
ZXING_VERSION=3.4.1
# Toolchain is the Java version used to build the application, which is separate from the # Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application. Android requires a minimum of 11. # Java version used to run the application. Android requires a minimum of 11.

View File

@ -9,20 +9,19 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressesTest { class WalletAddressesTest {
@Test @Test
@SmallTest @SmallTest
fun security() { fun security() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
val actual = WalletAddressesFixture.new().toString() val actual = WalletAddressesFixture.new().toString()
assertFalse(actual.contains(walletAddresses.shieldedOrchard)) assertFalse(actual.contains(walletAddresses.shieldedSapling.address))
assertFalse(actual.contains(walletAddresses.shieldedSapling)) assertFalse(actual.contains(walletAddresses.transparent.address))
assertFalse(actual.contains(walletAddresses.transparent)) assertFalse(actual.contains(walletAddresses.unified.address))
assertFalse(actual.contains(walletAddresses.unified))
assertFalse(actual.contains(walletAddresses.viewingKey)) assertFalse(actual.contains(walletAddresses.viewingKey))
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
@SmallTest @SmallTest
fun new() = runTest { fun new() = runTest {

View File

@ -0,0 +1,18 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.WalletAddress
object WalletAddressFixture {
// These fixture values are derived from the secret defined in PersistableWalletFixture
// TODO [#161]: Pending SDK support
const val UNIFIED_ADDRESS_STRING = "Unified GitHub Issue #161"
@Suppress("MaxLineLength")
const val SHIELDED_SAPLING_ADDRESS_STRING = "ztestsapling1475xtm56czrzmleqzzlu4cxvjjfsy2p6rv78q07232cpsx5ee52k0mn5jyndq09mampkgvrxnwg"
const val TRANSPARENT_ADDRESS_STRING = "tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp"
suspend fun unified() = WalletAddress.Unified.new(UNIFIED_ADDRESS_STRING)
suspend fun shieldedSapling() = WalletAddress.ShieldedSapling.new(SHIELDED_SAPLING_ADDRESS_STRING)
suspend fun transparent() = WalletAddress.Transparent.new(TRANSPARENT_ADDRESS_STRING)
}

View File

@ -1,22 +1,21 @@
package cash.z.ecc.sdk.fixture package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.WalletAddresses import cash.z.ecc.sdk.model.WalletAddresses
object WalletAddressesFixture { object WalletAddressesFixture {
// These fixture values are derived from the secret defined in PersistableWalletFixture // These fixture values are derived from the secret defined in PersistableWalletFixture
const val UNIFIED = "Unified GitHub Issue #161"
const val SHIELDED_ORCHARD = "Shielded Orchard GitHub Issue #161"
@Suppress("MaxLineLength")
const val SHIELDED_SAPLING = "ztestsapling1475xtm56czrzmleqzzlu4cxvjjfsy2p6rv78q07232cpsx5ee52k0mn5jyndq09mampkgvrxnwg"
const val TRANSPARENT = "tmXuTnE11JojToagTqxXUn6KvdxDE3iLKbp"
const val VIEWING_KEY = "03feaa290589a20f795f302ba03847b0a6c9c2b571d75d80bc4ebb02382d0549da" const val VIEWING_KEY = "03feaa290589a20f795f302ba03847b0a6c9c2b571d75d80bc4ebb02382d0549da"
fun new( suspend fun new(
unified: String = UNIFIED, unified: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING,
shieldedOrchard: String = SHIELDED_ORCHARD, shieldedSapling: String = WalletAddressFixture.SHIELDED_SAPLING_ADDRESS_STRING,
shieldedSapling: String = SHIELDED_SAPLING, transparent: String = WalletAddressFixture.TRANSPARENT_ADDRESS_STRING,
transparent: String = TRANSPARENT,
viewingKey: String = VIEWING_KEY viewingKey: String = VIEWING_KEY
) = WalletAddresses(unified, shieldedOrchard, shieldedSapling, transparent, viewingKey) ) = WalletAddresses(
WalletAddress.Unified.new(unified),
WalletAddress.ShieldedSapling.new(shieldedSapling),
WalletAddress.Transparent.new(transparent),
viewingKey
)
} }

View File

@ -0,0 +1,46 @@
package cash.z.ecc.sdk.model
sealed class WalletAddress(val address: String) {
class Unified private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): WalletAddress.Unified {
// https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return WalletAddress.Unified(address)
}
}
}
class ShieldedSapling private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): WalletAddress.ShieldedSapling {
// https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return WalletAddress.ShieldedSapling(address)
}
}
}
class Transparent private constructor(address: String) : WalletAddress(address) {
companion object {
suspend fun new(address: String): WalletAddress.Transparent {
// https://github.com/zcash/zcash-android-wallet-sdk/issues/342
// TODO [#342]: refactor SDK to enable direct calls for address verification
return WalletAddress.Transparent(address)
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WalletAddress
if (address != other.address) return false
return true
}
override fun hashCode() = address.hashCode()
}

View File

@ -7,10 +7,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
data class WalletAddresses( data class WalletAddresses(
val unified: String, val unified: WalletAddress.Unified,
val shieldedOrchard: String, val shieldedSapling: WalletAddress.ShieldedSapling,
val shieldedSapling: String, val transparent: WalletAddress.Transparent,
val transparent: String,
val viewingKey: String val viewingKey: String
) { ) {
// Override to prevent leaking details in logs // Override to prevent leaking details in logs
@ -32,16 +31,19 @@ data class WalletAddresses(
val shieldedSaplingAddress = withContext(Dispatchers.IO) { val shieldedSaplingAddress = withContext(Dispatchers.IO) {
DerivationTool.deriveShieldedAddress(bip39Seed, persistableWallet.network) DerivationTool.deriveShieldedAddress(bip39Seed, persistableWallet.network)
}.let {
WalletAddress.ShieldedSapling.new(it)
} }
val transparentAddress = withContext(Dispatchers.IO) { val transparentAddress = withContext(Dispatchers.IO) {
DerivationTool.deriveTransparentAddress(bip39Seed, persistableWallet.network) DerivationTool.deriveTransparentAddress(bip39Seed, persistableWallet.network)
}.let {
WalletAddress.Transparent.new(it)
} }
// TODO [#161]: Pending SDK support, fix providing correct values for the unified // TODO [#161]: Pending SDK support, fix providing correct values for the unified
return WalletAddresses( return WalletAddresses(
unified = "Unified GitHub Issue #161", unified = WalletAddress.Unified.new("Unified GitHub Issue #161"),
shieldedOrchard = "Shielded Orchard GitHub Issue #161",
shieldedSapling = shieldedSaplingAddress, shieldedSapling = shieldedSaplingAddress,
transparent = transparentAddress, transparent = transparentAddress,
viewingKey = viewingKey viewingKey = viewingKey

View File

@ -71,6 +71,7 @@ dependencyResolutionManagement {
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString() val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString() val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString() val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
val zxingVersion = extra["ZXING_VERSION"].toString()
// Standalone versions // Standalone versions
version("jacoco", jacocoVersion) version("jacoco", jacocoVersion)
@ -103,6 +104,7 @@ dependencyResolutionManagement {
alias("zcash-sdk").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion") alias("zcash-sdk").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
alias("zcash-bip39").to("cash.z.ecc.android:kotlin-bip39:$zcashBip39Version") alias("zcash-bip39").to("cash.z.ecc.android:kotlin-bip39:$zcashBip39Version")
alias("zcash-walletplgns").to("cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version") alias("zcash-walletplgns").to("cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version")
alias("zxing").to("com.google.zxing:core:$zxingVersion")
// Test libraries // Test libraries
alias("androidx-compose-test-junit").to("androidx.compose.ui:ui-test-junit4:$androidxComposeVersion") alias("androidx-compose-test-junit").to("androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")
alias("androidx-compose-test-manifest").to("androidx.compose.ui:ui-test-manifest:$androidxComposeVersion") alias("androidx-compose-test-manifest").to("androidx.compose.ui:ui-test-manifest:$androidxComposeVersion")

View File

@ -31,6 +31,7 @@ android {
"src/main/res/ui/common", "src/main/res/ui/common",
"src/main/res/ui/home", "src/main/res/ui/home",
"src/main/res/ui/onboarding", "src/main/res/ui/onboarding",
"src/main/res/ui/profile",
"src/main/res/ui/restore", "src/main/res/ui/restore",
"src/main/res/ui/wallet_address" "src/main/res/ui/wallet_address"
) )
@ -52,6 +53,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash.sdk) implementation(libs.zcash.sdk)
implementation(libs.zcash.bip39) implementation(libs.zcash.bip39)
implementation(libs.zxing)
implementation(projects.preferenceApiLib) implementation(projects.preferenceApiLib)
implementation(projects.preferenceImplAndroidLib) implementation(projects.preferenceImplAndroidLib)

View File

@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import kotlin.time.Duration import kotlin.time.Duration
class MainActivityTest { class MainActivityCompanionTest {
@Test @Test
@SmallTest @SmallTest

View File

@ -0,0 +1,200 @@
package cash.z.ecc.ui.screen.profile.view
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.compose.ui.test.performScrollTo
import androidx.test.filters.MediumTest
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.ui.R
import cash.z.ecc.ui.test.getStringResource
import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
/*
* Note: It is difficult to test the QR code from automated tests. There is a manual test case
* for that currently. A future enhancement could take a screenshot and try to analyze the
* screenshot contents.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun setup() = runTest {
val walletAddress = WalletAddressFixture.unified()
newTestSetup(walletAddress)
// Enable substring for ellipsizing
composeTestRule.onNodeWithText(walletAddress.address, substring = true).also {
it.assertExists()
}
}
@Test
@MediumTest
fun back() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.profile_back_content_description)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun address_details() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnAddressDetailsCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_see_address_details)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnAddressDetailsCount())
}
@Test
@MediumTest
fun address_book() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnAddressBookCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_address_book)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnAddressBookCount())
}
@Test
@MediumTest
fun settings() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnSettingsCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_settings)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnSettingsCount())
}
@Test
@MediumTest
fun coinholder_vote() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnCoinholderVoteCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_coinholder_vote)).also {
it.performScrollTo()
it.performClick()
}
assertEquals(1, testSetup.getOnCoinholderVoteCount())
}
@Test
@MediumTest
fun support() = runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnSupportCount())
composeTestRule.onNodeWithText(getStringResource(R.string.profile_support)).also {
it.performScrollTo()
it.assertExists()
it.performClick()
}
assertEquals(1, testSetup.getOnSupportCount())
}
private fun newTestSetup(walletAddress: WalletAddress) = TestSetup(composeTestRule, walletAddress)
private class TestSetup(private val composeTestRule: ComposeContentTestRule, walletAddress: WalletAddress) {
private val onBackCount = AtomicInteger(0)
private val onAddressDetailsCount = AtomicInteger(0)
private val onAddressBookCount = AtomicInteger(0)
private val onSettingsCount = AtomicInteger(0)
private val onCoinholderVoteCount = AtomicInteger(0)
private val onSupportCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
}
fun getOnAddressDetailsCount(): Int {
composeTestRule.waitForIdle()
return onAddressDetailsCount.get()
}
fun getOnAddressBookCount(): Int {
composeTestRule.waitForIdle()
return onAddressBookCount.get()
}
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getOnCoinholderVoteCount(): Int {
composeTestRule.waitForIdle()
return onCoinholderVoteCount.get()
}
fun getOnSupportCount(): Int {
composeTestRule.waitForIdle()
return onSupportCount.get()
}
init {
composeTestRule.setContent {
ZcashTheme {
Profile(
walletAddress,
onBack = {
onBackCount.getAndIncrement()
},
onAddressDetails = {
onAddressDetailsCount.getAndIncrement()
},
onAddressBook = {
onAddressBookCount.getAndIncrement()
},
onSettings = {
onSettingsCount.getAndIncrement()
},
onCoinholderVote = {
onCoinholderVoteCount.getAndIncrement()
},
onSupport = {
onSupportCount.getAndIncrement()
}
)
}
}
}
}
}

View File

@ -11,26 +11,26 @@ import cash.z.ecc.sdk.model.WalletAddresses
import cash.z.ecc.ui.R import cash.z.ecc.ui.R
import cash.z.ecc.ui.test.getStringResource import cash.z.ecc.ui.test.getStringResource
import cash.z.ecc.ui.theme.ZcashTheme import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressViewTest { class WalletAddressViewTest {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()
@Test @Test
@MediumTest @MediumTest
fun initial_screen_setup() { fun initial_screen_setup() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses) newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_unified)).also { composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_unified)).also {
it.assertExists() it.assertExists()
} }
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_orchard)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_sapling)).also { composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_sapling)).also {
it.assertExists() it.assertExists()
} }
@ -41,17 +41,14 @@ class WalletAddressViewTest {
it.assertExists() it.assertExists()
} }
composeTestRule.onNodeWithText(walletAddresses.unified).also { composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertExists() it.assertExists()
} }
composeTestRule.onNodeWithText(walletAddresses.shieldedOrchard).also { composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling).also { composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(walletAddresses.transparent).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
composeTestRule.onNodeWithText(walletAddresses.viewingKey).also { composeTestRule.onNodeWithText(walletAddresses.viewingKey).also {
@ -61,11 +58,11 @@ class WalletAddressViewTest {
@Test @Test
@MediumTest @MediumTest
fun unified_collapses() { fun unified_collapses() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses) newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.unified).also { composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertExists() it.assertExists()
} }
@ -74,38 +71,18 @@ class WalletAddressViewTest {
it.performClick() it.performClick()
} }
composeTestRule.onNodeWithText(walletAddresses.unified).also { composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
} }
@Test @Test
@MediumTest @MediumTest
fun shielded_orchard_expands() { fun shielded_sapling_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses) newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.shieldedOrchard).also { composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_orchard)).also {
it.assertExists()
it.performClick()
}
composeTestRule.onNodeWithText(walletAddresses.shieldedOrchard).also {
it.assertExists()
}
}
@Test
@MediumTest
fun shielded_sapling_expands() {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
@ -114,18 +91,18 @@ class WalletAddressViewTest {
it.performClick() it.performClick()
} }
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling).also { composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertExists() it.assertExists()
} }
} }
@Test @Test
@MediumTest @MediumTest
fun transparent_expands() { fun transparent_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses) newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.transparent).also { composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
@ -134,14 +111,14 @@ class WalletAddressViewTest {
it.performClick() it.performClick()
} }
composeTestRule.onNodeWithText(walletAddresses.transparent).also { composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertExists() it.assertExists()
} }
} }
@Test @Test
@MediumTest @MediumTest
fun viewing_expands() { fun viewing_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new() val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses) newTestSetup(walletAddresses)
@ -161,8 +138,8 @@ class WalletAddressViewTest {
@Test @Test
@MediumTest @MediumTest
fun back() { fun back() = runTest {
val testSetup = newTestSetup() val testSetup = newTestSetup(WalletAddressesFixture.new())
assertEquals(0, testSetup.getOnBackCount()) assertEquals(0, testSetup.getOnBackCount())
@ -173,7 +150,7 @@ class WalletAddressViewTest {
assertEquals(1, testSetup.getOnBackCount()) assertEquals(1, testSetup.getOnBackCount())
} }
private fun newTestSetup(initialState: WalletAddresses = WalletAddressesFixture.new()) = TestSetup(composeTestRule, initialState) private fun newTestSetup(initialState: WalletAddresses) = TestSetup(composeTestRule, initialState)
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: WalletAddresses) { private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialState: WalletAddresses) {

View File

@ -34,6 +34,7 @@ import cash.z.ecc.ui.screen.home.viewmodel.SecretState
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.ui.screen.onboarding.view.Onboarding import cash.z.ecc.ui.screen.onboarding.view.Onboarding
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
import cash.z.ecc.ui.screen.profile.view.Profile
import cash.z.ecc.ui.screen.restore.view.RestoreWallet import cash.z.ecc.ui.screen.restore.view.RestoreWallet
import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState import cash.z.ecc.ui.screen.restore.viewmodel.CompleteWordSetState
import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel import cash.z.ecc.ui.screen.restore.viewmodel.RestoreViewModel
@ -203,11 +204,21 @@ class MainActivity : ComponentActivity() {
composable("home") { composable("home") {
WrapHome( WrapHome(
goScan = {}, goScan = {},
goProfile = { navController.navigate("wallet_address_details") }, goProfile = { navController.navigate("profile") },
goSend = {}, goSend = {},
goRequest = {} goRequest = {}
) )
} }
composable("profile") {
WrapProfile(
onBack = { navController.popBackStack() },
onAddressDetails = { navController.navigate("wallet_address_details") },
onAddressBook = { },
onSettings = { },
onCoinholderVote = { }
) {
}
}
composable("wallet_address_details") { composable("wallet_address_details") {
WrapWalletAddresses( WrapWalletAddresses(
goBack = { goBack = {
@ -240,6 +251,32 @@ 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 @Composable
private fun WrapWalletAddresses( private fun WrapWalletAddresses(
goBack: () -> Unit, goBack: () -> Unit,

View File

@ -1,5 +1,6 @@
package cash.z.ecc.ui.screen.common package cash.z.ecc.ui.screen.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button import androidx.compose.material.Button
@ -9,9 +10,25 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cash.z.ecc.ui.theme.ZcashTheme import cash.z.ecc.ui.theme.ZcashTheme
@Preview
@Composable
fun ButtonComposablePreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Column {
PrimaryButton(onClick = { }, text = "Primary")
SecondaryButton(onClick = { }, text = "Secondary")
TertiaryButton(onClick = { }, text = "Tertiary")
NavigationButton(onClick = { }, text = "Navigation")
}
}
}
}
@Composable @Composable
fun PrimaryButton( fun PrimaryButton(
onClick: () -> Unit, onClick: () -> Unit,

View File

@ -0,0 +1,23 @@
package cash.z.ecc.ui.screen.profile.util
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
object AndroidQrCodeImageGenerator : QrCodeImageGenerator {
override fun generate(bitArray: BooleanArray, sizePixels: Int): ImageBitmap {
val colorArray = bitArray.toBlackAndWhiteColorArray()
return Bitmap.createBitmap(colorArray, sizePixels, sizePixels, Bitmap.Config.ARGB_8888)
.asImageBitmap()
}
}
private fun BooleanArray.toBlackAndWhiteColorArray() = IntArray(size) {
if (this[it]) {
Color.BLACK
} else {
Color.WHITE
}
}

View File

@ -0,0 +1,22 @@
package cash.z.ecc.ui.screen.profile.util
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
object JvmQrCodeGenerator : QrCodeGenerator {
override fun generate(data: String, sizePixels: Int): BooleanArray {
val bitMatrix = QRCodeWriter().let {
it.encode(data, BarcodeFormat.QR_CODE, sizePixels, sizePixels)
}
return BooleanArray(sizePixels * sizePixels).apply {
var booleanArrayPosition = 0
for (bitMatrixX in 0 until sizePixels) {
for (bitMatrixY in 0 until sizePixels) {
this[booleanArrayPosition] = bitMatrix.get(bitMatrixX, bitMatrixY)
booleanArrayPosition++
}
}
}
}
}

View File

@ -0,0 +1,10 @@
package cash.z.ecc.ui.screen.profile.util
interface QrCodeGenerator {
/**
* @param data Data to encode into the QR code.
* @param sizePixels Size in pixels of the QR code.
* @return A QR code pixel matrix, represented as an array of booleans where false is white and true is black.
*/
fun generate(data: String, sizePixels: Int): BooleanArray
}

View File

@ -0,0 +1,7 @@
package cash.z.ecc.ui.screen.profile.util
import androidx.compose.ui.graphics.ImageBitmap
interface QrCodeImageGenerator {
fun generate(bitArray: BooleanArray, sizePixels: Int): ImageBitmap
}

View File

@ -0,0 +1,151 @@
package cash.z.ecc.ui.screen.profile.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.ui.R
import cash.z.ecc.ui.screen.common.Body
import cash.z.ecc.ui.screen.common.GradientSurface
import cash.z.ecc.ui.screen.common.PrimaryButton
import cash.z.ecc.ui.screen.common.TertiaryButton
import cash.z.ecc.ui.screen.profile.util.AndroidQrCodeImageGenerator
import cash.z.ecc.ui.screen.profile.util.JvmQrCodeGenerator
import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.runBlocking
import kotlin.math.roundToInt
@Preview
@Composable
fun ComposablePreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Profile(
walletAddress = runBlocking { WalletAddressFixture.unified() },
onBack = {},
onAddressDetails = {},
onAddressBook = {},
onSettings = {},
onCoinholderVote = {},
onSupport = {}
)
}
}
}
@Composable
@Suppress("LongParameterList")
fun Profile(
walletAddress: WalletAddress,
onBack: () -> Unit,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit
) {
Column {
ProfileTopAppBar(onBack)
ProfileContents(
walletAddress = walletAddress,
onAddressDetails = onAddressDetails,
onAddressBook = onAddressBook,
onSettings = onSettings,
onCoinholderVote = onCoinholderVote,
onSupport = onSupport
)
}
}
@Composable
private fun ProfileTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = {
Text(
text = stringResource(id = R.string.profile_title)
)
},
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.profile_back_content_description),
tint = MaterialTheme.colors.secondary
)
}
}
)
}
private val DEFAULT_QR_CODE_SIZE = 320.dp
@Composable
@Suppress("LongParameterList")
private fun ProfileContents(
walletAddress: WalletAddress,
onAddressDetails: () -> Unit,
onAddressBook: () -> Unit,
onSettings: () -> Unit,
onCoinholderVote: () -> Unit,
onSupport: () -> Unit
) {
Column(Modifier.verticalScroll(rememberScrollState())) {
QrCode(data = walletAddress.address, DEFAULT_QR_CODE_SIZE, Modifier.align(Alignment.CenterHorizontally))
Body(text = stringResource(id = R.string.wallet_address_unified), Modifier.align(Alignment.CenterHorizontally))
// TODO [#163]: Ellipsize center of the string
Text(
text = walletAddress.address,
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.align(Alignment.CenterHorizontally),
overflow = TextOverflow.Ellipsis
)
PrimaryButton(onClick = onAddressDetails, text = stringResource(id = R.string.profile_see_address_details))
TertiaryButton(onClick = onAddressBook, text = stringResource(id = R.string.profile_address_book))
TertiaryButton(onClick = onSettings, text = stringResource(id = R.string.profile_settings))
Divider()
TertiaryButton(onClick = onCoinholderVote, text = stringResource(id = R.string.profile_coinholder_vote))
TertiaryButton(onClick = onSupport, text = stringResource(id = R.string.profile_support))
}
}
@Composable
private fun QrCode(data: String, size: Dp, modifier: Modifier) {
val sizePixels = with(LocalDensity.current) { size.toPx() }.roundToInt()
// In the future, use actual/expect to switch QR code generator implementations for multiplatform
// Note that our implementation has an extra array copy to BooleanArray, which is a cross-platform
// representation. This should have minimal performance impact since the QR code is relatively
// small and we only generate QR codes infrequently.
val qrCodePixelArray = JvmQrCodeGenerator.generate(data, sizePixels)
val qrCodeImage = AndroidQrCodeImageGenerator.generate(qrCodePixelArray, sizePixels)
Image(
bitmap = qrCodeImage,
contentDescription = stringResource(R.string.profile_qr_code_content_description),
modifier = modifier
)
}

View File

@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.Icon import androidx.compose.material.Icon
@ -47,6 +46,7 @@ import cash.z.ecc.ui.screen.common.ListHeader
import cash.z.ecc.ui.screen.common.ListItem import cash.z.ecc.ui.screen.common.ListItem
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
import cash.z.ecc.ui.theme.ZcashTheme import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.runBlocking
@Preview @Preview
@Composable @Composable
@ -54,7 +54,7 @@ fun ComposablePreview() {
ZcashTheme(darkTheme = true) { ZcashTheme(darkTheme = true) {
GradientSurface { GradientSurface {
WalletAddresses( WalletAddresses(
WalletAddressesFixture.new(), runBlocking { WalletAddressesFixture.new() },
onBack = {} onBack = {}
) )
} }
@ -79,7 +79,6 @@ private fun WalletDetailTopAppBar(onBack: () -> Unit) {
}, },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
modifier = Modifier.padding(16.dp),
onClick = onBack onClick = onBack
) { ) {
Icon( Icon(
@ -114,7 +113,7 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
ExpandableRow( ExpandableRow(
title = stringResource(R.string.wallet_address_unified), title = stringResource(R.string.wallet_address_unified),
content = walletAddresses.unified, content = walletAddresses.unified.address,
isInitiallyExpanded = true isInitiallyExpanded = true
) )
@ -123,9 +122,8 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
ListHeader(text = stringResource(R.string.wallet_address_header_includes)) ListHeader(text = stringResource(R.string.wallet_address_header_includes))
} }
OrchardAddress(walletAddresses.shieldedOrchard) SaplingAddress(walletAddresses.shieldedSapling.address)
SaplingAddress(walletAddresses.shieldedSapling) TransparentAddress(walletAddresses.transparent.address)
TransparentAddress(walletAddresses.transparent)
} }
} }
} }
@ -139,22 +137,6 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
// Refactoring that is being held off until issue #160 is fixed, since knowledge // Refactoring that is being held off until issue #160 is fixed, since knowledge
// of row position will be needed. // of row position will be needed.
@Composable
private fun OrchardAddress(orchardAddress: String) {
Row(
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
) {
SmallIndicator(ZcashTheme.colors.addressHighlightOrchard)
ExpandableRow(
title = stringResource(R.string.wallet_address_shielded_orchard),
content = orchardAddress,
isInitiallyExpanded = false
)
}
}
@Composable @Composable
private fun SaplingAddress(saplingAddress: String) { private fun SaplingAddress(saplingAddress: String) {
Row( Row(

View File

@ -44,7 +44,6 @@ object Dark {
val addressHighlightBorder = Color(0xFF525252) val addressHighlightBorder = Color(0xFF525252)
val addressHighlightUnified = Color(0xFFFFD800) val addressHighlightUnified = Color(0xFFFFD800)
val addressHighlightOrchard = Color(0xFFFFD800)
val addressHighlightSapling = Color(0xFF1BBFF6) val addressHighlightSapling = Color(0xFF1BBFF6)
val addressHighlightTransparent = Color(0xFF97999A) val addressHighlightTransparent = Color(0xFF97999A)
val addressHighlightViewing = Color(0xFF504062) val addressHighlightViewing = Color(0xFF504062)
@ -91,7 +90,6 @@ object Light {
// [TODO #159]: The colors are wrong for light theme // [TODO #159]: The colors are wrong for light theme
val addressHighlightBorder = Color(0xFF525252) val addressHighlightBorder = Color(0xFF525252)
val addressHighlightUnified = Color(0xFFFFD800) val addressHighlightUnified = Color(0xFFFFD800)
val addressHighlightOrchard = Color(0xFFFFD800)
val addressHighlightSapling = Color(0xFF1BBFF6) val addressHighlightSapling = Color(0xFF1BBFF6)
val addressHighlightTransparent = Color(0xFF97999A) val addressHighlightTransparent = Color(0xFF97999A)
val addressHighlightViewing = Color(0xFF504062) val addressHighlightViewing = Color(0xFF504062)

View File

@ -49,7 +49,6 @@ data class ExtendedColors(
val highlight: Color, val highlight: Color,
val addressHighlightBorder: Color, val addressHighlightBorder: Color,
val addressHighlightUnified: Color, val addressHighlightUnified: Color,
val addressHighlightOrchard: Color,
val addressHighlightSapling: Color, val addressHighlightSapling: Color,
val addressHighlightTransparent: Color, val addressHighlightTransparent: Color,
val addressHighlightViewing: Color val addressHighlightViewing: Color
@ -78,7 +77,6 @@ val DarkExtendedColorPalette = ExtendedColors(
highlight = Dark.highlight, highlight = Dark.highlight,
addressHighlightBorder = Dark.addressHighlightBorder, addressHighlightBorder = Dark.addressHighlightBorder,
addressHighlightUnified = Dark.addressHighlightUnified, addressHighlightUnified = Dark.addressHighlightUnified,
addressHighlightOrchard = Dark.addressHighlightOrchard,
addressHighlightSapling = Dark.addressHighlightSapling, addressHighlightSapling = Dark.addressHighlightSapling,
addressHighlightTransparent = Dark.addressHighlightTransparent, addressHighlightTransparent = Dark.addressHighlightTransparent,
addressHighlightViewing = Dark.addressHighlightViewing addressHighlightViewing = Dark.addressHighlightViewing
@ -99,7 +97,6 @@ val LightExtendedColorPalette = ExtendedColors(
highlight = Light.highlight, highlight = Light.highlight,
addressHighlightBorder = Light.addressHighlightBorder, addressHighlightBorder = Light.addressHighlightBorder,
addressHighlightUnified = Light.addressHighlightUnified, addressHighlightUnified = Light.addressHighlightUnified,
addressHighlightOrchard = Light.addressHighlightOrchard,
addressHighlightSapling = Light.addressHighlightSapling, addressHighlightSapling = Light.addressHighlightSapling,
addressHighlightTransparent = Light.addressHighlightTransparent, addressHighlightTransparent = Light.addressHighlightTransparent,
addressHighlightViewing = Light.addressHighlightViewing addressHighlightViewing = Light.addressHighlightViewing
@ -121,7 +118,6 @@ val LocalExtendedColors = staticCompositionLocalOf {
highlight = Color.Unspecified, highlight = Color.Unspecified,
addressHighlightBorder = Color.Unspecified, addressHighlightBorder = Color.Unspecified,
addressHighlightUnified = Color.Unspecified, addressHighlightUnified = Color.Unspecified,
addressHighlightOrchard = Color.Unspecified,
addressHighlightSapling = Color.Unspecified, addressHighlightSapling = Color.Unspecified,
addressHighlightTransparent = Color.Unspecified, addressHighlightTransparent = Color.Unspecified,
addressHighlightViewing = Color.Unspecified addressHighlightViewing = Color.Unspecified

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="profile_title">Profile</string>
<string name="profile_back_content_description">Back</string>
<string name="profile_qr_code_content_description">QR code for unified address</string>
<string name="profile_caption">Your UA Address</string>
<string name="profile_see_address_details">See Address Details</string>
<string name="profile_address_book">Address Book</string>
<string name="profile_settings">Settings</string>
<string name="profile_coinholder_vote">Coinholder Vote</string>
<string name="profile_support">Support</string>
</resources>

View File

@ -4,7 +4,6 @@
<string name="wallet_address_back_content_description">Back</string> <string name="wallet_address_back_content_description">Back</string>
<string name="wallet_address_unified">Your Unified Address</string> <string name="wallet_address_unified">Your Unified Address</string>
<string name="wallet_address_header_includes">which includes</string> <string name="wallet_address_header_includes">which includes</string>
<string name="wallet_address_shielded_orchard">Shielded Orchard (NU5)</string>
<string name="wallet_address_shielded_sapling">Shielded Sapling (NU1)</string> <string name="wallet_address_shielded_sapling">Shielded Sapling (NU1)</string>
<string name="wallet_address_transparent">Transparent</string> <string name="wallet_address_transparent">Transparent</string>
<string name="wallet_address_viewing_key">Viewing Key Only (Sapling)</string> <string name="wallet_address_viewing_key">Viewing Key Only (Sapling)</string>