Merge pull request #25 from zcash/15-onboarding

This commit is contained in:
Carter Jernigan 2021-10-11 15:44:38 -04:00 committed by GitHub
commit 361c6a2eee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1176 additions and 91 deletions

1
.gitignore vendored
View File

@ -2,7 +2,6 @@ syntax: glob
.DS_Store
.gradle/
.idea/caches
.idea/codeStyles
.idea/compiler.xml
.idea/dictionaries/
.idea/gradle.xml

View File

@ -0,0 +1,137 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@ -0,0 +1,53 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ui-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.ui-lib" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View File

@ -6,7 +6,7 @@ plugins {
id("zcash.android-build-conventions")
}
val packageName = "cash.z.ecc.android"
val packageName = "cash.z.ecc"
android {
defaultConfig {
@ -15,15 +15,6 @@ android {
versionName = "1.0"
}
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
}
flavorDimensions.add("network")
productFlavors {
@ -99,6 +90,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash)
implementation(projects.uiLib)
androidTestImplementation(libs.bundles.androidx.test)
}

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="cash.z.ecc.android.sdk.demoapp">
package="cash.z.ecc">
<application
android:name="cash.z.ecc.android.app.App"
android:name="cash.z.ecc.app.AppImpl"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -18,20 +18,13 @@
<activity-alias
android:name=".LauncherActivity"
android:label="@string/"
android:targetActivity="cash.z.ecc.android.app.MainActivity"
android:targetActivity="cash.z.ecc.ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name="cash.z.ecc.android.app.MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.MyApplication"
android:exported="false">
</activity>
</application>
</manifest>

View File

@ -1,19 +0,0 @@
package cash.z.ecc.android.app
import android.app.Application
import cash.z.ecc.android.sdk.demoapp.BuildConfig
import cash.z.ecc.android.sdk.ext.TroubleshootingTwig
import cash.z.ecc.android.sdk.ext.Twig
class App : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictModeHelper.enableStrictMode()
}
Twig.plant(TroubleshootingTwig())
}
}

View File

@ -1,38 +0,0 @@
package cash.z.ecc.android.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.app.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
MyApplicationTheme {
Greeting("Android")
}
}

View File

@ -0,0 +1,16 @@
package cash.z.ecc.app
import android.app.Application
import cash.z.ecc.BuildConfig
@Suppress("unused")
class AppImpl : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictModeHelper.enableStrictMode()
}
}
}

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.app
package cash.z.ecc.app
import android.annotation.SuppressLint
import android.os.Build

View File

@ -75,6 +75,7 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
}
}
// TODO [#22]: This doesn't work, so there's a duplicate in build.gradle.kts
testOptions {
animationsDisabled = true
@ -82,4 +83,23 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
packagingOptions {
resources.excludes.addAll(
listOf(
"META-INF/AL2.0",
"META-INF/ASL2.0",
"META-INF/DEPENDENCIES",
"META-INF/LGPL2.1",
"META-INF/LICENSE",
"META-INF/LICENSE-notice.md",
"META-INF/LICENSE.md",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/license.txt",
"META-INF/notice.txt",
)
)
}
}

View File

@ -4,4 +4,15 @@ TODO This is a placeholder for describing the app architecture.
* Versions are declared in [gradle.properties](../gradle.properties). There's still enough inconsistency in how versions are handled in Gradle, that this is as close as we can get to a universal system. A version catalog is used for dependencies and is configured in [settings.gradle.kts](../settings.gradle.kts), but other versions like Gradle Plug-ins, the NDK version, Java version, and Android SDK versions don't fit into the version catalog model and are read directly from the properties
* Much of the Gradle configuration lives in [build-conventions](../build-conventions/) to prevent repetitive configuration as additional modules are added to the project
* Build scripts are written in Kotlin, so that a single language is used across build and the app code bases
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negative impact building, testing, or deploying the app
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negative impact building, testing, or deploying the app
## App
The main entrypoints of the application are:
* [AppImpl.kt](../app/src/main/java/cash/z/ecc/app/AppImpl.kt) - The root Application object defined in the app module
* [MainActivity.kt](../ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt) - The main Activity, defined in ui-lib. Note that the Activity is NOT exported. Instead, the app module defines an activity-alias in the AndroidManifest which is what presents the actual icon on the Android home screen.
## Modules
The logical components of the app are implemented as a number of Gradle modules.
* app — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
* ui-lib — User interface that the user interacts with. This contains 99% of the UI code, along with localizations, icons, and other assets.

View File

@ -14,7 +14,7 @@ Start by making sure the command line with Gradle works first, because **all the
1. Java 16 is currently recommended. Java 11 is the minimum requirement for Android Studio.
1. To simplify installation, use [Oracle's JDK](https://www.oracle.com/java/technologies/javase-jdk16-downloads.html) installer that will place the Java installation in the right place
1. Install Android Studio and the Android SDK
1. Download the [stable release of Android Studio](https://developer.android.com/studio#downloads)
1. Download the [Android Studio Bumblebee Canary](https://developer.android.com/studio/preview) (we're using the Canary version, due to its improved integration with Jetpack Compose)
1. TODO: Fill in step-by-step instructions for setting up a new environment and installing the Android SDK from within Android Studio
1. Check out the code. _Use the command line (instead of Android Studio) to check out the code. This will ensure that your command line environment is set up correctly and avoids a few pitfalls with trying to use Android Studio directly. Android Studio's built-in git client is not as robust as standalone clients_
1. Compile from the command line

View File

@ -54,6 +54,7 @@ ANDROIDX_APPCOMPAT_VERSION=1.3.1
ANDROIDX_COMPOSE_VERSION=1.0.2
ANDROIDX_CORE_VERSION=1.6.0
ANDROIDX_ESPRESSO_VERSION=3.4.0
ANDROIDX_LIFECYCLE_VERSION=2.3.1
ANDROIDX_NAVIGATION_VERSION=2.3.5
ANDROIDX_TEST_JUNIT_VERSION=1.1.3
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.1.0-alpha1

View File

@ -35,6 +35,7 @@ dependencyResolutionManagement {
val androidxComposeVersion = extra["ANDROIDX_COMPOSE_VERSION"].toString()
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
val androidxEspressoVersion = extra["ANDROIDX_ESPRESSO_VERSION"].toString()
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
val androidxTestJunitVersion = extra["ANDROIDX_TEST_JUNIT_VERSION"].toString()
val androidxTestOrchestratorVersion = extra["ANDROIDX_ESPRESSO_VERSION"].toString()
val androidxUiAutomatorVersion = extra["ANDROIDX_UI_AUTOMATOR_VERSION"].toString()
@ -53,23 +54,26 @@ dependencyResolutionManagement {
// Aliases
alias("androidx-activity").to("androidx.activity:activity-ktx:$androidxActivityVersion")
alias("androidx-activity-compose").to("androidx.activity:activity-compose:$androidxActivityVersion")
alias("androidx-appcompat").to("androidx.appcompat:appcompat:$androidxAppcompatVersion")
alias("androidx-annotation").to("androidx.annotation:annotation:$androidxAnnotationVersion")
alias("androidx-appcompat").to("androidx.appcompat:appcompat:$androidxAppcompatVersion")
alias("androidx-compose-foundation").to("androidx.compose.foundation:foundation:$androidxComposeVersion")
alias("androidx-compose-material").to("androidx.compose.material:material:$androidxComposeVersion")
alias("androidx-compose-material-icons-core").to("androidx.compose.material:material-icons-core:$androidxComposeVersion")
alias("androidx-compose-tooling").to("androidx.compose.ui:ui-tooling-preview:$androidxComposeVersion")
alias("androidx-compose-tooling").to("androidx.compose.ui:ui-tooling:$androidxComposeVersion")
alias("androidx-compose-ui").to("androidx.compose.ui:ui:$androidxComposeVersion")
alias("androidx-core").to("androidx.core:core-ktx:$androidxCoreVersion")
alias("androidx-lifecycle-livedata").to("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
alias("androidx-viewmodel-compose").to("androidx.activity:activity-compose:$androidxActivityVersion")
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
alias("kotlin").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
alias("kotlinx-coroutines-android").to("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
alias("kotlinx-coroutines-core").to("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
alias("zcash").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
// Test libraries
alias("androidx-espresso-contrib").to("androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
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")
// Cannot use espresso-contrib, because it causes a build failure
//alias("androidx-espresso-contrib").to("androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
alias("androidx-espresso-core").to("androidx.test.espresso:espresso-core:$androidxEspressoVersion")
alias("androidx-espresso-intents").to("androidx.test.espresso:espresso-intents:$androidxEspressoVersion")
alias("androidx-junit").to("androidx.test.ext:junit:$androidxTestJunitVersion")
@ -95,7 +99,6 @@ dependencyResolutionManagement {
listOf(
"androidx-espresso-core",
"androidx-espresso-intents",
"androidx-espresso-contrib",
"androidx-junit"
)
)
@ -108,3 +111,4 @@ rootProject.name = "zcash-android-app"
includeBuild("build-conventions")
include("app")
include("ui-lib")

48
ui-lib/build.gradle.kts Normal file
View File

@ -0,0 +1,48 @@
plugins {
id("com.android.library")
kotlin("android")
id("kotlin-parcelize")
id("androidx.navigation.safeargs")
id("zcash.android-build-conventions")
}
val packageName = "cash.z.ecc.ui"
android {
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
}
// TODO [#5]: Figure out how to move this into the build-conventions
testCoverage {
jacocoVersion = libs.versions.jacoco.get()
}
// TODO [#6]: Figure out how to move this into the build-conventions
kotlinOptions {
jvmTarget = libs.versions.java.get()
allWarningsAsErrors = project.property("IS_TREAT_WARNINGS_AS_ERRORS").toString().toBoolean()
}
}
dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.annotation)
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.bundles.androidx.compose)
implementation(libs.google.material)
implementation(libs.kotlin)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.androidx.compose.test.junit)
androidTestImplementation(libs.androidx.compose.test.manifest)
}

View File

@ -0,0 +1,12 @@
package cash.z.ecc.ui.screen.onboarding.model
import androidx.test.filters.SmallTest
import org.junit.Test
class IndexTest {
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun out_of_bounds() {
Index(-1)
}
}

View File

@ -0,0 +1,80 @@
package cash.z.ecc.ui.screen.onboarding.model
import androidx.test.filters.SmallTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Test
class OnboardingStageTest {
@Test
@SmallTest
fun getProgress_first() {
val progress = OnboardingStage.values().first().getProgress()
assertEquals(0, progress.current.value)
assertEquals(3, progress.last.value)
}
@Test
@SmallTest
fun getProgress_last() {
val progress = OnboardingStage.values().last().getProgress()
assertEquals(3, progress.current.value)
assertEquals(3, progress.last.value)
}
@Test
@SmallTest
fun hasNext_boundary() {
val last = OnboardingStage.values().last()
assertFalse(last.hasNext())
}
@Test
@SmallTest
fun hasPrevious_boundary() {
val last = OnboardingStage.values().first()
assertFalse(last.hasPrevious())
}
@Test
@SmallTest
fun getNext_from_first() {
val first = OnboardingStage.values().first()
val next = first.getNext()
assertNotEquals(first, next)
assertEquals(OnboardingStage.UnifiedAddresses, next)
}
@Test
@SmallTest
fun getNext_boundary() {
val last = OnboardingStage.values().last()
assertEquals(last, last.getNext())
}
@Test
@SmallTest
fun getPrevious_from_last() {
val last = OnboardingStage.values().last()
val previous = last.getPrevious()
assertNotEquals(last, previous)
assertEquals(OnboardingStage.More, previous)
}
@Test
@SmallTest
fun getPrevious_boundary() {
val first = OnboardingStage.values().first()
assertEquals(first, first.getPrevious())
}
}

View File

@ -0,0 +1,19 @@
package cash.z.ecc.ui.screen.onboarding.model
import androidx.test.filters.SmallTest
import org.junit.Test
class PercentDecimalTest {
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun require_greater_than_zero() {
PercentDecimal(-1.0f)
}
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun require_less_than_one() {
PercentDecimal(1.5f)
}
}

View File

@ -0,0 +1,19 @@
package cash.z.ecc.ui.screen.onboarding.model
import androidx.test.filters.SmallTest
import org.junit.Test
class ProgressTest {
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun last_greater_than_zero() {
Progress(current = Index(0), last = Index(0))
}
@Test(expected = IllegalArgumentException::class)
@SmallTest
fun last_greater_or_equal_to_current() {
Progress(current = Index(5), last = Index(4))
}
}

View File

@ -0,0 +1,7 @@
package cash.z.ecc.ui.screen.onboarding.test
import android.content.Context
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)

View File

@ -0,0 +1,276 @@
package cash.z.ecc.ui.screen.onboarding.view
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import cash.z.ecc.R
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
import cash.z.ecc.ui.screen.onboarding.test.getStringResource
import cash.z.ecc.ui.theme.MyApplicationTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class OnboardingViewTest {
@get:Rule
val composeTestRule = createComposeRule()
// Sanity check the TestSetup
@Test
@MediumTest
fun verify_test_setup_stage_1() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
assertEquals(OnboardingStage.ShieldedByDefault, testSetup.getOnboardingStage())
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
}
@Test
@MediumTest
fun verify_test_setup_stage_4() {
val testSetup = newTestSetup(OnboardingStage.Wallet)
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
}
@Test
@MediumTest
fun stage_1_layout() {
newTestSetup(OnboardingStage.ShieldedByDefault)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_header)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_body)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun stage_2_layout() {
newTestSetup(OnboardingStage.UnifiedAddresses)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_2_header)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_2_body)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun stage_3_layout() {
newTestSetup(OnboardingStage.More)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
it.assertExists()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_3_header)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_3_body)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun stage_4_layout() {
newTestSetup(OnboardingStage.Wallet)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
it.assertExists()
it.assertIsEnabled()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
it.assertExists()
it.assertIsEnabled()
it.assertHasClickAction()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
it.assertExists()
it.assertIsEnabled()
it.assertHasClickAction()
}
}
@Test
@MediumTest
fun stage_1_skip() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
val skipButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip))
skipButton.performClick()
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
}
@Test
@MediumTest
fun last_stage_click_create_wallet() {
val testSetup = newTestSetup(OnboardingStage.Wallet)
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet))
newWalletButton.performClick()
assertEquals(1, testSetup.getOnCreateWalletCallbackCount())
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
}
@Test
@MediumTest
fun last_stage_click_import_wallet() {
val testSetup = newTestSetup(OnboardingStage.Wallet)
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet))
newWalletButton.performClick()
assertEquals(1, testSetup.getOnImportWalletCallbackCount())
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
}
@Test
@MediumTest
fun multi_stage_progression() {
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.performClick()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.performClick()
}
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
it.performClick()
}
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
}
private fun newTestSetup(initalStage: OnboardingStage) = TestSetup(composeTestRule, initalStage)
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: OnboardingStage) {
private val onboardingState = OnboardingState(initalStage)
private var onCreateWalletCallbackCount = 0
private var onImportWalletCallbackCount = 0
fun getOnCreateWalletCallbackCount(): Int {
composeTestRule.waitForIdle()
return onCreateWalletCallbackCount
}
fun getOnImportWalletCallbackCount(): Int {
composeTestRule.waitForIdle()
return onImportWalletCallbackCount
}
fun getOnboardingStage(): OnboardingStage {
composeTestRule.waitForIdle()
return onboardingState.current.value
}
init {
composeTestRule.setContent {
MyApplicationTheme {
Onboarding(
onboardingState,
onCreateWallet = { onCreateWalletCallbackCount++ },
onImportWallet = { onImportWalletCallbackCount++ }
)
}
}
}
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="cash.z.ecc.ui">
<application>
<activity
android:name="cash.z.ecc.ui.MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.MyApplication"
android:exported="false">
</activity>
</application>
</manifest>

View File

@ -0,0 +1,27 @@
package cash.z.ecc.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
import cash.z.ecc.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
private val onboardingViewModel by viewModels<OnboardingViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Onboarding(
onboardingState = onboardingViewModel.onboardingState,
onImportWallet = { TODO("Implement wallet import") },
onCreateWallet = { TODO("Implement wallet create") }
)
}
}
}
}

View File

@ -0,0 +1,13 @@
package cash.z.ecc.ui.screen.onboarding.model
/**
* Useful for accessing arrays or lists by index.
*
* @param value A 0-based index. Must be >= 0
*/
@JvmInline
value class Index(val value: Int) {
init {
require(value >= 0) { "Index must be >= 0 but actually is $value" }
}
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.ui.screen.onboarding.model
enum class OnboardingStage {
// Note: the ordinal order is used to manage progression through each stage
// so be careful if reordering these
ShieldedByDefault,
UnifiedAddresses,
More,
Wallet;
/**
* @see getPrevious
*/
fun hasPrevious() = ordinal > 0
/**
* @see getNext
*/
fun hasNext() = ordinal < values().size - 1
/**
* @return Previous item in ordinal order. Returns the first item when it cannot go further back.
*/
fun getPrevious() = values()[maxOf(0, ordinal - 1)]
/**
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
*/
fun getNext() = values()[minOf(values().size - 1, ordinal + 1)]
/**
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
*/
fun getProgress() = Progress(Index(ordinal), Index(values().size - 1))
}

View File

@ -0,0 +1,15 @@
package cash.z.ecc.ui.screen.onboarding.model
/**
* @param decimal A percent represented as a `Double` decimal value in the range of [0, 1].
*/
@JvmInline
value class PercentDecimal(val decimal: Float) {
init {
require(EXPECTED_RANGE.contains(decimal)) { "$decimal is outside of range $EXPECTED_RANGE" }
}
companion object {
private val EXPECTED_RANGE = 0.0f..1.0f
}
}

View File

@ -0,0 +1,14 @@
package cash.z.ecc.ui.screen.onboarding.model
data class Progress(val current: Index, val last: Index) {
init {
require(last.value > 0) { "last must be > 0 but was $last" }
require(last.value >= current.value) { "last ($last) must be >= current ($current)" }
}
fun percent() = PercentDecimal((current.value + 1).toFloat() / (last.value + 1).toFloat())
companion object {
val EMPTY = Progress(Index(0), Index(1))
}
}

View File

@ -0,0 +1,33 @@
package cash.z.ecc.ui.screen.onboarding.state
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* @param initialState Allows restoring the state from a different starting point. This is
* primarily useful on Android, for automated tests, and for iterative debugging with the Compose
* layout preview. The default constructor argument is generally fine for other platforms.
*/
class OnboardingState(initialState: OnboardingStage = OnboardingStage.values().first()) {
private val mutableState = MutableStateFlow(initialState)
val current: StateFlow<OnboardingStage> = mutableState
fun hasNext() = current.value.hasNext()
fun hasPrevious() = current.value.hasPrevious()
fun goNext() {
mutableState.value = current.value.getNext()
}
fun goPrevious() {
mutableState.value = current.value.getPrevious()
}
fun goToEnd() {
mutableState.value = OnboardingStage.values().last()
}
}

View File

@ -0,0 +1,165 @@
package cash.z.ecc.ui.screen.onboarding.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.R
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
import cash.z.ecc.ui.screen.onboarding.model.Progress
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
@Preview
@Composable
fun ComposablePreview() {
Onboarding(
OnboardingState(OnboardingStage.UnifiedAddresses),
onImportWallet = {},
onCreateWallet = {}
)
}
/**
* @param onImportWallet Callback when the user decides to import an existing wallet.
* @param onCreateWallet Callback when the user decides to create a new wallet.
*/
@Composable
fun Onboarding(
onboardingState: OnboardingState,
onImportWallet: () -> Unit,
onCreateWallet: () -> Unit
) {
Column {
TopNavButtons(onboardingState)
val onboardingStage = onboardingState.current.collectAsState().value
when (onboardingStage) {
OnboardingStage.ShieldedByDefault -> ShieldedByDefault()
OnboardingStage.UnifiedAddresses -> UnifiedAddresses()
OnboardingStage.More -> More()
OnboardingStage.Wallet -> Wallet(
onCreateWallet = onCreateWallet,
onImportWallet = onImportWallet
)
}
BottomNav(onboardingStage.getProgress(), onboardingState::goNext)
}
}
@Composable
private fun TopNavButtons(onboardingState: OnboardingState) {
Row {
if (onboardingState.hasPrevious()) {
Button(onboardingState::goPrevious) {
Text(stringResource(R.string.onboarding_back))
}
}
Spacer(Modifier.fillMaxWidth().weight(MINIMAL_WEIGHT, true))
if (onboardingState.hasNext()) {
Button(onboardingState::goToEnd) {
Text(stringResource(R.string.onboarding_skip))
}
}
}
}
@Composable
private fun BottomNav(progress: Progress, onNext: () -> Unit) {
if (progress.current != progress.last) {
Column {
Button(onNext, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.onboarding_next))
}
// Converts from index to human numbering
Text((progress.current.value + 1).toString())
LinearProgressIndicator(progress = progress.percent().decimal, Modifier.fillMaxWidth())
}
}
}
@Composable
private fun ShieldedByDefault() {
Column {
Content(
image = ColorPainter(Color.Blue),
imageContentDescription = stringResource(R.string.onboarding_1_image_content_description),
headline = stringResource(R.string.onboarding_1_header),
body = stringResource(R.string.onboarding_1_body)
)
}
}
@Composable
private fun UnifiedAddresses() {
Column {
Content(
image = ColorPainter(Color.Blue),
imageContentDescription = stringResource(R.string.onboarding_2_image_content_description),
headline = stringResource(R.string.onboarding_2_header),
body = stringResource(R.string.onboarding_2_body)
)
}
}
@Composable
private fun More() {
Column {
Content(
image = ColorPainter(Color.Blue),
imageContentDescription = stringResource(R.string.onboarding_3_image_content_description),
headline = stringResource(R.string.onboarding_3_header),
body = stringResource(R.string.onboarding_3_body)
)
}
}
@Composable
private fun Wallet(onCreateWallet: () -> Unit, onImportWallet: () -> Unit) {
Column {
Button(onCreateWallet, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.onboarding_4_create_new_wallet))
}
Button(onImportWallet, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.onboarding_4_import_existing_wallet))
}
}
}
@Composable
private fun Content(
image: Painter,
imageContentDescription: String?,
headline: String,
body: String
) {
Column(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
// TODO [#17]: This suppression and magic number will get replaced once we have real assets
@Suppress("MagicNumber")
Image(image, imageContentDescription, Modifier.fillMaxSize(0.50f))
}
Text(headline)
Text(body)
}
}

View File

@ -0,0 +1,45 @@
package cash.z.ecc.ui.screen.onboarding.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
/*
* Android-specific ViewModel. This is used to save and restore state across Activity recreations
* outside of the Compose framework.
*/
class OnboardingViewModel(
application: Application,
savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
val onboardingState: OnboardingState = run {
val initialValue = if (savedStateHandle.contains(KEY_STAGE)) {
savedStateHandle.get<OnboardingStage>(KEY_STAGE)
} else {
null
}
if (null == initialValue) {
OnboardingState()
} else {
OnboardingState(initialValue)
}
}
init {
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
// update the save state as soon as a change occurs.
onboardingState.current.collectWith(viewModelScope) {
savedStateHandle.set(KEY_STAGE, it)
}
}
companion object {
private const val KEY_STAGE = "stage" // $NON-NLS
}
}

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.app.ui.theme
package cash.z.ecc.ui.theme
import androidx.compose.ui.graphics.Color

View File

@ -0,0 +1,6 @@
package cash.z.ecc.ui.theme
/**
* A tiny weight, useful for spacers to fill an empty space.
*/
const val MINIMAL_WEIGHT = 0.0001f

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.app.ui.theme
package cash.z.ecc.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.app.ui.theme
package cash.z.ecc.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
@ -30,7 +30,7 @@ private val LightColorPalette = lightColors(
@Composable
fun MyApplicationTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette

View File

@ -1,4 +1,4 @@
package cash.z.ecc.android.app.ui.theme
package cash.z.ecc.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle

View File

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.MyApplication" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,21 @@
<resources>
<string name="app_name">Demo App</string>
<string name="onboarding_back">Back</string>
<string name="onboarding_skip">Skip</string>
<string name="onboarding_next">Next</string>
<string name="onboarding_1_header">Shielded by Default</string>
<string name="onboarding_1_body">Tired of worrying about which wallet you used last? US TOO! Now you dont have to, as all funds will automatically be moved to your shielded wallet (and migrated for you).</string>
<string name="onboarding_1_image_content_description"></string>
<string name="onboarding_2_header">Unified Addresses</string>
<string name="onboarding_2_body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget metus euismod, hendrerit dui vel, faucibus ante. Curabitur tortor elit, varius eu leo id, fringilla auctor odio. Donec fringilla tortor purus.</string>
<string name="onboarding_2_image_content_description"></string>
<string name="onboarding_3_header">And so much more…</string>
<string name="onboarding_3_body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget metus euismod, hendrerit dui vel, faucibus ante. Curabitur tortor elit, varius eu leo id, fringilla auctor odio. Donec fringilla tortor purus.</string>
<string name="onboarding_3_image_content_description"></string>
<string name="onboarding_4_header">Ready for the Future</string>
<string name="onboarding_4_body">Lets get you set up!</string>
<string name="onboarding_4_image_content_description"></string>
<string name="onboarding_4_create_new_wallet">Create New Wallet</string>
<string name="onboarding_4_import_existing_wallet">Import an Existing Wallet</string>
</resources>

View File

@ -0,0 +1,25 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.MyApplication" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
<style name="Theme.MyApplication.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.MyApplication.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
<style name="Theme.MyApplication.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>

View File

@ -0,0 +1,4 @@
<resources>
<string name="app_name">Mainnet Demo</string>
<string name="network_name">Mainnet</string>
</resources>

View File

@ -0,0 +1,4 @@
<resources>
<string name="app_name">Testnet Demo</string>
<string name="network_name">Testnet</string>
</resources>