[#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_BIP39_VERSION=1.0.2
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
# 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.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressesTest {
@Test
@SmallTest
fun security() {
fun security() = runTest {
val walletAddresses = WalletAddressesFixture.new()
val actual = WalletAddressesFixture.new().toString()
assertFalse(actual.contains(walletAddresses.shieldedOrchard))
assertFalse(actual.contains(walletAddresses.shieldedSapling))
assertFalse(actual.contains(walletAddresses.transparent))
assertFalse(actual.contains(walletAddresses.unified))
assertFalse(actual.contains(walletAddresses.shieldedSapling.address))
assertFalse(actual.contains(walletAddresses.transparent.address))
assertFalse(actual.contains(walletAddresses.unified.address))
assertFalse(actual.contains(walletAddresses.viewingKey))
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
@SmallTest
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
import cash.z.ecc.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.WalletAddresses
object WalletAddressesFixture {
// 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"
fun new(
unified: String = UNIFIED,
shieldedOrchard: String = SHIELDED_ORCHARD,
shieldedSapling: String = SHIELDED_SAPLING,
transparent: String = TRANSPARENT,
suspend fun new(
unified: String = WalletAddressFixture.UNIFIED_ADDRESS_STRING,
shieldedSapling: String = WalletAddressFixture.SHIELDED_SAPLING_ADDRESS_STRING,
transparent: String = WalletAddressFixture.TRANSPARENT_ADDRESS_STRING,
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
data class WalletAddresses(
val unified: String,
val shieldedOrchard: String,
val shieldedSapling: String,
val transparent: String,
val unified: WalletAddress.Unified,
val shieldedSapling: WalletAddress.ShieldedSapling,
val transparent: WalletAddress.Transparent,
val viewingKey: String
) {
// Override to prevent leaking details in logs
@ -32,16 +31,19 @@ data class WalletAddresses(
val shieldedSaplingAddress = withContext(Dispatchers.IO) {
DerivationTool.deriveShieldedAddress(bip39Seed, persistableWallet.network)
}.let {
WalletAddress.ShieldedSapling.new(it)
}
val transparentAddress = withContext(Dispatchers.IO) {
DerivationTool.deriveTransparentAddress(bip39Seed, persistableWallet.network)
}.let {
WalletAddress.Transparent.new(it)
}
// TODO [#161]: Pending SDK support, fix providing correct values for the unified
return WalletAddresses(
unified = "Unified GitHub Issue #161",
shieldedOrchard = "Shielded Orchard GitHub Issue #161",
unified = WalletAddress.Unified.new("Unified GitHub Issue #161"),
shieldedSapling = shieldedSaplingAddress,
transparent = transparentAddress,
viewingKey = viewingKey

View File

@ -71,6 +71,7 @@ dependencyResolutionManagement {
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
val zxingVersion = extra["ZXING_VERSION"].toString()
// Standalone versions
version("jacoco", jacocoVersion)
@ -103,6 +104,7 @@ dependencyResolutionManagement {
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-walletplgns").to("cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version")
alias("zxing").to("com.google.zxing:core:$zxingVersion")
// Test libraries
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")

View File

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

View File

@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.time.Duration
class MainActivityTest {
class MainActivityCompanionTest {
@Test
@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.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
@OptIn(ExperimentalCoroutinesApi::class)
class WalletAddressViewTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun initial_screen_setup() {
fun initial_screen_setup() = runTest {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_unified)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_orchard)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.wallet_address_shielded_sapling)).also {
it.assertExists()
}
@ -41,17 +41,14 @@ class WalletAddressViewTest {
it.assertExists()
}
composeTestRule.onNodeWithText(walletAddresses.unified).also {
composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertExists()
}
composeTestRule.onNodeWithText(walletAddresses.shieldedOrchard).also {
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(walletAddresses.transparent).also {
composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(walletAddresses.viewingKey).also {
@ -61,11 +58,11 @@ class WalletAddressViewTest {
@Test
@MediumTest
fun unified_collapses() {
fun unified_collapses() = runTest {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.unified).also {
composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertExists()
}
@ -74,38 +71,18 @@ class WalletAddressViewTest {
it.performClick()
}
composeTestRule.onNodeWithText(walletAddresses.unified).also {
composeTestRule.onNodeWithText(walletAddresses.unified.address).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun shielded_orchard_expands() {
fun shielded_sapling_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.shieldedOrchard).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 {
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertDoesNotExist()
}
@ -114,18 +91,18 @@ class WalletAddressViewTest {
it.performClick()
}
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling).also {
composeTestRule.onNodeWithText(walletAddresses.shieldedSapling.address).also {
it.assertExists()
}
}
@Test
@MediumTest
fun transparent_expands() {
fun transparent_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
composeTestRule.onNodeWithText(walletAddresses.transparent).also {
composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertDoesNotExist()
}
@ -134,14 +111,14 @@ class WalletAddressViewTest {
it.performClick()
}
composeTestRule.onNodeWithText(walletAddresses.transparent).also {
composeTestRule.onNodeWithText(walletAddresses.transparent.address).also {
it.assertExists()
}
}
@Test
@MediumTest
fun viewing_expands() {
fun viewing_expands() = runTest {
val walletAddresses = WalletAddressesFixture.new()
newTestSetup(walletAddresses)
@ -161,8 +138,8 @@ class WalletAddressViewTest {
@Test
@MediumTest
fun back() {
val testSetup = newTestSetup()
fun back() = runTest {
val testSetup = newTestSetup(WalletAddressesFixture.new())
assertEquals(0, testSetup.getOnBackCount())
@ -173,7 +150,7 @@ class WalletAddressViewTest {
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) {

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

View File

@ -1,5 +1,6 @@
package cash.z.ecc.ui.screen.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
@ -9,9 +10,25 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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
fun PrimaryButton(
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
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.theme.MINIMAL_WEIGHT
import cash.z.ecc.ui.theme.ZcashTheme
import kotlinx.coroutines.runBlocking
@Preview
@Composable
@ -54,7 +54,7 @@ fun ComposablePreview() {
ZcashTheme(darkTheme = true) {
GradientSurface {
WalletAddresses(
WalletAddressesFixture.new(),
runBlocking { WalletAddressesFixture.new() },
onBack = {}
)
}
@ -79,7 +79,6 @@ private fun WalletDetailTopAppBar(onBack: () -> Unit) {
},
navigationIcon = {
IconButton(
modifier = Modifier.padding(16.dp),
onClick = onBack
) {
Icon(
@ -114,7 +113,7 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
Column(Modifier.fillMaxWidth()) {
ExpandableRow(
title = stringResource(R.string.wallet_address_unified),
content = walletAddresses.unified,
content = walletAddresses.unified.address,
isInitiallyExpanded = true
)
@ -123,9 +122,8 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
ListHeader(text = stringResource(R.string.wallet_address_header_includes))
}
OrchardAddress(walletAddresses.shieldedOrchard)
SaplingAddress(walletAddresses.shieldedSapling)
TransparentAddress(walletAddresses.transparent)
SaplingAddress(walletAddresses.shieldedSapling.address)
TransparentAddress(walletAddresses.transparent.address)
}
}
}
@ -139,22 +137,6 @@ private fun WalletDetailAddresses(walletAddresses: WalletAddresses) {
// Refactoring that is being held off until issue #160 is fixed, since knowledge
// 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
private fun SaplingAddress(saplingAddress: String) {
Row(

View File

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

View File

@ -49,7 +49,6 @@ data class ExtendedColors(
val highlight: Color,
val addressHighlightBorder: Color,
val addressHighlightUnified: Color,
val addressHighlightOrchard: Color,
val addressHighlightSapling: Color,
val addressHighlightTransparent: Color,
val addressHighlightViewing: Color
@ -78,7 +77,6 @@ val DarkExtendedColorPalette = ExtendedColors(
highlight = Dark.highlight,
addressHighlightBorder = Dark.addressHighlightBorder,
addressHighlightUnified = Dark.addressHighlightUnified,
addressHighlightOrchard = Dark.addressHighlightOrchard,
addressHighlightSapling = Dark.addressHighlightSapling,
addressHighlightTransparent = Dark.addressHighlightTransparent,
addressHighlightViewing = Dark.addressHighlightViewing
@ -99,7 +97,6 @@ val LightExtendedColorPalette = ExtendedColors(
highlight = Light.highlight,
addressHighlightBorder = Light.addressHighlightBorder,
addressHighlightUnified = Light.addressHighlightUnified,
addressHighlightOrchard = Light.addressHighlightOrchard,
addressHighlightSapling = Light.addressHighlightSapling,
addressHighlightTransparent = Light.addressHighlightTransparent,
addressHighlightViewing = Light.addressHighlightViewing
@ -121,7 +118,6 @@ val LocalExtendedColors = staticCompositionLocalOf {
highlight = Color.Unspecified,
addressHighlightBorder = Color.Unspecified,
addressHighlightUnified = Color.Unspecified,
addressHighlightOrchard = Color.Unspecified,
addressHighlightSapling = Color.Unspecified,
addressHighlightTransparent = 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_unified">Your Unified Address</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_transparent">Transparent</string>
<string name="wallet_address_viewing_key">Viewing Key Only (Sapling)</string>