[#269] Implement autoshield information prompt

The prompt should be displayed the first time the user visits the home screen after creating/restoring a wallet.
This commit is contained in:
Carter Jernigan 2021-09-02 13:48:21 -04:00
parent 06a25e9e96
commit 1875c5f298
30 changed files with 756 additions and 25 deletions

View File

@ -0,0 +1,53 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="app:androidTest" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="Zcash_Wallet.app" />
<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="2147483645" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="api-9130115880275692386-873230" />
<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

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dependencyUpdates" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="dependencyUpdates" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@ -20,8 +20,10 @@ android {
targetSdkVersion Deps.targetSdkVersion
versionCode = Deps.versionCode
versionName = Deps.versionName
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunnerArguments clearPackageData: 'true'
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (Boolean.parseBoolean(isUseTestOrchestrator)) {
testInstrumentationRunnerArguments clearPackageData: 'true'
}
multiDexEnabled true
resValue 'string', 'bugsnag_api_key', "${(project.findProperty('BUGSNAG_API_KEY') ?: System.getenv('BUGSNAG_API_KEY')) ?: ''}"
@ -92,7 +94,9 @@ android {
// freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.FlowPreview"
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
if (Boolean.parseBoolean(isUseTestOrchestrator)) {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
kapt {
arguments {
@ -183,8 +187,15 @@ dependencies {
testImplementation Deps.Test.MOKITO
testImplementation Deps.Test.MOKITO_KOTLIN
androidTestImplementation Deps.Kotlin.REFLECT
androidTestImplementation Deps.Test.Android.JUNIT
androidTestImplementation Deps.Test.Android.CORE
androidTestImplementation Deps.Test.Android.FRAGMENT
androidTestImplementation Deps.Test.Android.ESPRESSO
androidTestImplementation Deps.Test.Android.ESPRESSO_INTENTS
androidTestImplementation Deps.Test.Android.NAVIGATION
// androidTestImplementation is preferred, but then the androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity isn't available
debugImplementation Deps.Test.Android.FRAGMENT
}
defaultTasks 'clean', 'assembleZcashmainnetRelease'

View File

@ -1,11 +1,7 @@
package cash.z.ecc.android
import androidx.test.ext.junit.runners.AndroidJUnit4
import cash.z.ecc.android.ui.util.MemoUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@ -13,11 +9,11 @@ import org.junit.runners.Parameterized
// @RunWith(Parameterized::class)
class MemoTest(val input: String, val output: String) {
@Test
fun testExtractValidAddress() = runBlocking {
val result = MemoUtil.firstValidAddress(input, ::validateMemo)
assertEquals(output, result)
}
// @Test
// fun testExtractValidAddress() = runBlocking {
// val result = MemoUtil.findAddressInMemo(input, ::validateMemo)
// assertEquals(output, result)
// }
suspend fun validateMemo(memo: String): Boolean {
delay(20)

View File

@ -6,6 +6,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.kotlin.mnemonic.Mnemonics
import okio.Buffer
import okio.GzipSink
import okio.Okio

View File

@ -1,10 +1,12 @@
package cash.z.ecc.android.integration
import android.content.Context
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.lockbox.LockBox
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
class LockBoxTest {
@ -22,6 +24,8 @@ class LockBoxTest {
}
@Test
@LargeTest
@Ignore("This test is extremely slow")
fun testLongString() {
var successCount = 0
repeat(iterations) {
@ -36,6 +40,8 @@ class LockBoxTest {
}
@Test
@LargeTest
@Ignore("This test is extremely slow")
fun testShortString() {
var successCount = 0
repeat(iterations) {
@ -50,6 +56,8 @@ class LockBoxTest {
}
@Test
@LargeTest
@Ignore("This test is extremely slow")
fun testGiantString() {
var successCount = 0
repeat(iterations) {

View File

@ -0,0 +1,41 @@
package cash.z.ecc.android.preference
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.reflect.Modifier
import kotlin.reflect.full.memberProperties
@RunWith(AndroidJUnit4::class)
class PreferenceKeysTest {
@SmallTest
@Test
@Throws(IllegalAccessException::class)
fun fields_public_static_and_final() {
PreferenceKeys::class.java.fields.forEach {
val modifiers = it.modifiers
assertThat(Modifier.isFinal(modifiers), equalTo(true))
assertThat(Modifier.isStatic(modifiers), equalTo(true))
assertThat(Modifier.isPublic(modifiers), equalTo(true))
}
}
// This test is primary to prevent copy-paste errors in preference keys
@SmallTest
@Test
fun key_values_unique() {
val fieldValueSet = mutableSetOf<String>()
PreferenceKeys::class.memberProperties
.map { it.getter.call() }
.map { it as String }
.forEach {
assertThat("Duplicate key $it", fieldValueSet.contains(it), equalTo(false))
fieldValueSet.add(it)
}
}
}

View File

@ -0,0 +1,46 @@
package cash.z.ecc.android.preference
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import cash.z.ecc.android.preference.model.DefaultValue
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.reflect.Modifier
import kotlin.reflect.full.memberProperties
@RunWith(AndroidJUnit4::class)
class PreferencesTest {
@SmallTest
@Test
@Throws(IllegalAccessException::class)
fun fields_public_static_and_final() {
Preferences::class.java.fields.forEach {
val modifiers = it.modifiers
assertThat(Modifier.isFinal(modifiers), equalTo(true))
assertThat(Modifier.isStatic(modifiers), equalTo(true))
assertThat(Modifier.isPublic(modifiers), equalTo(true))
}
}
// This test is primary to prevent copy-paste errors in preference keys
@SmallTest
@Test
fun key_values_unique() {
val fieldValueSet = mutableSetOf<String>()
Preferences::class.memberProperties
.map { it.getter.call(Preferences) }
.map { it as DefaultValue<*> }
.forEach {
assertThat(
"Duplicate key ${it.key}",
fieldValueSet.contains(it.key),
equalTo(false)
)
fieldValueSet.add(it.key)
}
}
}

View File

@ -0,0 +1,32 @@
package cash.z.ecc.android.test
import androidx.annotation.IdRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.testing.FragmentScenario
import androidx.navigation.Navigation
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
data class FragmentNavigationScenario<T : Fragment>(
val fragmentScenario: FragmentScenario<T>,
val navigationController: TestNavHostController
) {
companion object {
fun <T : Fragment> new(
fragmentScenario: FragmentScenario<T>,
@IdRes currentDestination: Int
): FragmentNavigationScenario<T> {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
fragmentScenario.onFragment {
navController.setGraph(cash.z.ecc.android.R.navigation.mobile_navigation)
navController.setCurrentDestination(currentDestination)
Navigation.setViewNavController(it.requireView(), navController)
}
return FragmentNavigationScenario(fragmentScenario, navController)
}
}
}

View File

@ -0,0 +1,29 @@
package cash.z.ecc.android.test
import android.annotation.TargetApi
import android.content.Context
import android.os.Build
import android.os.PowerManager
import androidx.test.core.app.ApplicationProvider
import org.junit.Before
import java.lang.AssertionError
/**
* Subclass this for UI tests to ensure they run correctly. This helps when developers run tests
* against a physical device that might have gone to sleep.
*/
open class UiTestPrerequisites {
@Before
fun verifyScreenOn() {
if (!isScreenOn()) {
throw AssertionError("Screen must be on for UI tests to run") // $NON-NLS
}
}
@TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
private fun isScreenOn(): Boolean {
val powerService = ApplicationProvider.getApplicationContext<Context>()
.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerService.isInteractive
}
}

View File

@ -0,0 +1,163 @@
package cash.z.ecc.android.ui.home
import android.content.ComponentName
import android.content.Context
import androidx.fragment.app.testing.FragmentScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import cash.z.ecc.android.preference.Preferences
import cash.z.ecc.android.preference.SharedPreferenceFactory
import cash.z.ecc.android.preference.model.get
import cash.z.ecc.android.test.FragmentNavigationScenario
import cash.z.ecc.android.test.UiTestPrerequisites
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AutoshieldingInformationFragmentTest : UiTestPrerequisites() {
@Test
@MediumTest
fun dismiss_returns_home() {
val fragmentNavigationScenario = newScenario()
onView(withId(cash.z.ecc.android.R.id.button_autoshield_dismiss)).also {
it.perform(ViewActions.click())
}
assertThat(
fragmentNavigationScenario.navigationController.currentDestination?.id,
equalTo(cash.z.ecc.android.R.id.nav_home)
)
}
@Test
@MediumTest
fun dismiss_sets_preference() {
newScenario()
onView(withId(cash.z.ecc.android.R.id.button_autoshield_dismiss)).also {
it.perform(ViewActions.click())
}
assertThat(
Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(ApplicationProvider.getApplicationContext<Context>()),
equalTo(true)
)
}
@Test
@MediumTest
fun clicking_more_info_launches_browser() {
val fragmentNavigationScenario = newScenario()
onView(withId(cash.z.ecc.android.R.id.button_autoshield_more_info)).also {
it.perform(ViewActions.click())
}
assertThat(
fragmentNavigationScenario.navigationController.currentDestination?.id,
equalTo(cash.z.ecc.android.R.id.nav_autoshielding_info_details)
)
// Note: it is difficult to verify that the browser is launched, because of how the
// navigation component works.
}
@Test
@MediumTest
fun clicking_more_info_sets_preference() {
newScenario()
onView(withId(cash.z.ecc.android.R.id.button_autoshield_more_info)).also {
it.perform(ViewActions.click())
}
assertThat(
Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(ApplicationProvider.getApplicationContext<Context>()),
equalTo(true)
)
}
@Test
@MediumTest
fun starting_fragment_does_not_launch_activities() {
Intents.init()
try {
val fragmentNavigationScenario = newScenario()
// The test framework launches an Activity to host the Fragment under test
// Since the class name is not a public API, this could break in the future with newer
// versions of the AndroidX Test libraries.
intended(
hasComponent(
ComponentName(
ApplicationProvider.getApplicationContext(),
"androidx.test.core.app.InstrumentationActivityInvoker\$BootstrapActivity"
)
)
)
// Verifying that no other Activities (e.g. the link view) are launched without explicit
// user interaction
Intents.assertNoUnverifiedIntents()
assertThat(
fragmentNavigationScenario.navigationController.currentDestination?.id,
equalTo(cash.z.ecc.android.R.id.nav_autoshielding_info)
)
} finally {
Intents.release()
}
}
@Test
@MediumTest
fun back_does_not_set_preference() {
val fragmentNavigationScenario = newScenario()
fragmentNavigationScenario.fragmentScenario.onFragment {
// Probably closest we can come to simulating back with the navigation test framework
fragmentNavigationScenario.navigationController.navigateUp()
}
assertThat(
fragmentNavigationScenario.navigationController.currentDestination?.id,
equalTo(cash.z.ecc.android.R.id.nav_home)
)
assertThat(
Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(ApplicationProvider.getApplicationContext<Context>()),
equalTo(false)
)
}
companion object {
private fun newScenario(): FragmentNavigationScenario<AutoshieldingInformationFragment> {
// Clear preferences for each scenario, as this most closely reflects how this fragment
// is used in the app, as it is displayed usually on first launch
SharedPreferenceFactory.getSharedPreferences(ApplicationProvider.getApplicationContext())
.edit().clear().apply()
val scenario = FragmentScenario.launchInContainer(
AutoshieldingInformationFragment::class.java,
null,
cash.z.ecc.android.R.style.ZcashTheme,
null
)
return FragmentNavigationScenario.new(
scenario,
cash.z.ecc.android.R.id.nav_autoshielding_info
)
}
}
}

View File

@ -0,0 +1,9 @@
package cash.z.ecc.android.ext
import androidx.fragment.app.Fragment
/**
* A safer alternative to [Fragment.requireContext], as it avoids leaking Fragment or Activity context
* when Application context is often sufficient.
*/
fun Fragment.requireApplicationContext() = requireContext().applicationContext

View File

@ -124,6 +124,7 @@ object Report {
SCAN,
AUTO_SHIELD_FINAL("autoshield.final"),
AUTO_SHIELD_AVAILABLE("autoshield.available"),
AUTO_SHIELD_INFORMATION("autoshield.information"),
SEND_ADDRESS("send.address"),
SEND_CONFIRM("send.confirm"),
SEND_FINAL("send.final"),

View File

@ -0,0 +1,5 @@
package cash.z.ecc.android.preference
internal object PreferenceKeys {
const val IS_AUTOSHIELDING_INFO_ACKNOWLEDGED = "is_autoshielding_info_acknowledged"
}

View File

@ -0,0 +1,8 @@
package cash.z.ecc.android.preference
import cash.z.ecc.android.preference.model.BooleanDefaultValue
object Preferences {
val isAcknowledgedAutoshieldingInformationPrompt =
BooleanDefaultValue(PreferenceKeys.IS_AUTOSHIELDING_INFO_ACKNOWLEDGED, false)
}

View File

@ -0,0 +1,9 @@
package cash.z.ecc.android.preference
import android.content.Context
object SharedPreferenceFactory {
private const val DEFAULT_SHARED_PREFERENCES = "cash.z.ecc.default"
fun getSharedPreferences(context: Context) = context.getSharedPreferences(DEFAULT_SHARED_PREFERENCES, Context.MODE_PRIVATE)
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.android.preference.model
import android.content.Context
import android.content.SharedPreferences
import cash.z.ecc.android.preference.SharedPreferenceFactory
/**
* A default value represents a preference key, along with its default value. It does not, by itself,
* know how to read or write values from the preference repository.
*/
data class BooleanDefaultValue(override val key: String, internal val defaultValue: Boolean) :
DefaultValue<Boolean> {
init {
require(key.isNotEmpty())
}
}
fun BooleanDefaultValue.get(context: Context) = get(
SharedPreferenceFactory.getSharedPreferences(
context
)
)
internal fun BooleanDefaultValue.get(sharedPreferences: SharedPreferences) =
sharedPreferences.getBoolean(key, defaultValue)
fun BooleanDefaultValue.put(context: Context, newValue: Boolean) = put(
SharedPreferenceFactory.getSharedPreferences(
context
),
newValue
)
internal fun BooleanDefaultValue.put(sharedPreferences: SharedPreferences, newValue: Boolean) =
sharedPreferences.edit().putBoolean(key, newValue).apply()

View File

@ -0,0 +1,23 @@
package cash.z.ecc.android.preference.model
/**
* A key and a default value for a key-value store of preferences.
*
* Use of this interface avoids duplication or accidental variation in default value, because key
* and default are defined together just once.
*
* Note that T is not fully generic and should be one of the supported types: Boolean, ... (other types to be added in the future)
*
* @see BooleanDefaultValue
*/
/*
* Although primitives would be nice, Objects don't increase memory usage much
* because of the autoboxing cache on the JVM. For example, Boolean's true/false values
* are cached.
*/
interface DefaultValue<T> {
// Note: the default value is not available through the public interface in order to prevent
// clients from accidentally using the default value instead of the stored value.
val key: String
}

View File

@ -87,7 +87,7 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainActivity : AppCompatActivity() {
class MainActivity : AppCompatActivity(R.layout.main_activity) {
@Inject
lateinit var mainViewModel: MainViewModel
@ -141,7 +141,6 @@ class MainActivity : AppCompatActivity() {
}
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
initNavigation()
initLoadScreen()
@ -653,6 +652,7 @@ class MainActivity : AppCompatActivity() {
dialogViewBinding.dialogMessage.setText(msgResId)
if (dialog != null) dialog?.dismiss()
// TODO: This should be moved to a DialogFragment, otherwise unmanaged dialogs go away during Activity configuration changes
dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleResId)
.setView(dialogViewBinding.root)

View File

@ -21,7 +21,15 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
abstract class BaseFragment<T : ViewBinding> : Fragment() {
val mainActivity: MainActivity? get() = activity as MainActivity?
// Normally will be of type MainActivity, but will be null when run under automated tests.
// A future enhancement would be to move analytics. For example, refactor it out of the Activity
// so that we don't have to cast. Or at least put analytics into an interface, so that we're more
// explicitly casting to Analytics rather than MainActivity.
val mainActivity: MainActivity? get() = if (activity is MainActivity) {
activity as MainActivity
} else {
null
}
lateinit var binding: T

View File

@ -0,0 +1,56 @@
package cash.z.ecc.android.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.navigation.fragment.findNavController
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentAutoShieldInformationBinding
import cash.z.ecc.android.ext.requireApplicationContext
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.preference.Preferences
import cash.z.ecc.android.preference.model.put
import cash.z.ecc.android.ui.base.BaseFragment
/*
* If the user presses the Android back button, the backstack will be popped and the user returns
* to the app home screen. The preference will not be set in that case, because it could be considered
* that the user did not acknowledge this prompt.
*/
class AutoshieldingInformationFragment : BaseFragment<FragmentAutoShieldInformationBinding>() {
override val screen = Report.Screen.AUTO_SHIELD_INFORMATION
override fun inflate(inflater: LayoutInflater): FragmentAutoShieldInformationBinding =
FragmentAutoShieldInformationBinding.inflate(inflater)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonAutoshieldDismiss.setOnClickListener {
Preferences.isAcknowledgedAutoshieldingInformationPrompt.put(
requireApplicationContext(),
true
)
findNavController().navigate(R.id.action_nav_autoshielding_info_to_home)
}
binding.buttonAutoshieldMoreInfo.setOnClickListener {
Preferences.isAcknowledgedAutoshieldingInformationPrompt.put(
requireApplicationContext(),
true
)
try {
findNavController().navigate(R.id.action_nav_autoshielding_info_to_browser)
} catch (e: Exception) {
// ActivityNotFoundException could happen on certain devices, like Android TV, Android Things, etc.
// SecurityException shouldn't occur, but just in case we catch all exceptions to
// prevent another package on the device from crashing us if that package tries to be malicious
// by adding permissions or changing export status dynamically.
// In the future, it might also be desirable to display a Toast or Snackbar indicating
// that the browser couldn't be launched
findNavController().navigate(R.id.action_nav_autoshielding_info_to_home)
}
}
}
}

View File

@ -20,6 +20,7 @@ import cash.z.ecc.android.ext.disabledIf
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.invisibleIf
import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.ext.requireApplicationContext
import cash.z.ecc.android.ext.showSharedLibraryCriticalError
import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.ext.toColoredSpan
@ -32,6 +33,8 @@ import cash.z.ecc.android.feedback.Report.Tap.HOME_HISTORY
import cash.z.ecc.android.feedback.Report.Tap.HOME_PROFILE
import cash.z.ecc.android.feedback.Report.Tap.HOME_RECEIVE
import cash.z.ecc.android.feedback.Report.Tap.HOME_SEND
import cash.z.ecc.android.preference.Preferences
import cash.z.ecc.android.preference.model.get
import cash.z.ecc.android.sdk.Synchronizer.Status.DISCONNECTED
import cash.z.ecc.android.sdk.Synchronizer.Status.STOPPED
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCED
@ -178,7 +181,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private fun onSyncReady() {
twig("Sync ready! Monitoring synchronizer state...")
monitorUiModelChanges()
maybeInterruptUser()
if (!Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(requireApplicationContext())) {
mainActivity?.safeNavigate(R.id.action_nav_home_to_autoshielding_info)
} else {
maybeInterruptUser()
}
twig("HomeFragment.onSyncReady COMPLETE")
}

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/background_home">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_hit_area_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.04" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_send_amount_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.13" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_content_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.15" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_content_end"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.85" />
<!-- TODO: the color isn't exactly right -->
<ImageView
android:id="@+id/icon_autoshielding"
android:layout_width="0dp"
android:layout_height="0dp"
android:elevation="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.212"
app:layout_constraintWidth_percent="0.4"
android:src="@drawable/ic_check_shield"
app:tint="@color/colorPrimary" />
<TextView
android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
style="@style/TextAppearance.AppCompat.Body1"
android:text="@string/autoshielding_title_text"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/icon_autoshielding" />
<TextView
android:id="@+id/text_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
style="@style/TextAppearance.AppCompat.Body1"
android:text="@string/autoshielding_body_text"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/text_title" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_autoshield_dismiss"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/Zcash.Button"
android:gravity="center"
android:padding="12dp"
android:text="@string/autoshielding_button_positive"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="#000000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/text_description"
app:layout_constraintVertical_bias="0.1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_autoshield_more_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/Zcash.Button.OutlinedButton"
android:gravity="center"
android:padding="12dp"
android:text="@string/autoshielding_button_neutral"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/button_autoshield_dismiss" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -19,6 +19,9 @@
<action
android:id="@+id/action_nav_home_to_create_wallet"
app:destination="@id/nav_landing" />
<action
android:id="@+id/action_nav_home_to_autoshielding_info"
app:destination="@id/nav_autoshielding_info" />
<action
android:id="@+id/action_nav_home_to_send"
app:destination="@id/nav_send"
@ -116,6 +119,26 @@
app:popUpTo="@id/nav_home"
app:popUpToInclusive="false"/>
</fragment>
<fragment
android:id="@+id/nav_autoshielding_info"
android:name="cash.z.ecc.android.ui.home.AutoshieldingInformationFragment"
tools:layout="@layout/fragment_auto_shield_information" >
<action
android:id="@+id/action_nav_autoshielding_info_to_home"
app:destination="@id/nav_home"
app:popUpTo="@id/nav_home"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_nav_autoshielding_info_to_browser"
app:destination="@id/nav_autoshielding_info_details"
app:popUpTo="@id/nav_home"
app:popUpToInclusive="true" />
</fragment>
<activity
android:id="@+id/nav_autoshielding_info_details"
app:action="android.intent.action.VIEW"
app:data="@string/autoshield_explanation_url" />
<!-- -->
<!-- Send Navigation -->
@ -229,7 +252,7 @@
app:popUpTo="@id/nav_home"
app:popUpToInclusive="false"/>
</fragment>
<!-- -->
<!-- Wallet Setup Navigation -->
<!-- -->
@ -266,6 +289,7 @@
</fragment>
<!-- -->
<!-- Global actions -->
<!-- -->

View File

@ -38,6 +38,11 @@
<string name="missing_home_dialog_no_balance_message">To make full use of this wallet, deposit funds to your address.</string>
<string name="missing_home_instruction_fund_now">Fund Now</string>
<string name="missing_autoshielding_title_text">We now <b>shield by default</b>. This is to ensure maximum privacy.</string>
<string name="missing_autoshielding_body_text"><b>Autoshielding</b> means any funds coming into your transparent address will automatically be moved to your shielded wallet.\n\nWell always update you on the latest privacy-preserving best practices and recommendations.</string>
<string name="missing_autoshielding_button_positive">Great!</string>
<string name="missing_autoshielding_button_neutral">Tell me more</string>
<!-- Screen: Landing -->
<string name="missing_landing_backup_skipped_message_1">Are you sure? Without a backup, funds can be lost FOREVER!</string>
<string name="missing_landing_backup_skipped_message_2">You can\'t backup later. You\'re probably going to lose your funds!</string>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- ======================================================================================= -->
<!-- Strings that are missing translations. Individually, these can be moved into -->
<!-- the 'translated-urls.xml' file, after they are translated to all supported languages -->
<!-- ======================================================================================= -->
<string name="missing_autoshield_explanation_url">https://electriccoin.co/blog/unified-addresses-in-zcash-explained/</string>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="autoshield_explanation_url">@string/missing_autoshield_explanation_url</string>
</resources>

View File

@ -56,6 +56,12 @@
<string name="home_no_balance">@string/translated_balance_nofunds</string>
<string name="home_title">@string/translated_balance_amounttosend</string>
<!-- Screen: Autoshielding prompt -->
<string name="autoshielding_title_text">@string/missing_autoshielding_title_text</string>
<string name="autoshielding_body_text">@string/missing_autoshielding_body_text</string>
<string name="autoshielding_button_positive">@string/missing_autoshielding_button_positive</string>
<string name="autoshielding_button_neutral">@string/missing_autoshielding_button_neutral</string>
<!-- Screen: Balance Detail -->
<string name="balance_detail_button_send_transparent_funds" tools:ignore="MissingTranslation">Shield Transparent Funds</string>
<string name="balance_detail_text_total_title" tools:ignore="MissingTranslation">= TOTAL</string>

View File

@ -6,7 +6,7 @@ object Deps {
const val kotlinVersion = "1.5.10"
const val navigationVersion = "2.3.5"
const val compileSdkVersion = 30
const val compileSdkVersion = 31
const val buildToolsVersion = "30.0.3"
const val minSdkVersion = 21
const val targetSdkVersion = 30
@ -20,8 +20,8 @@ object Deps {
const val APPCOMPAT = "androidx.appcompat:appcompat:1.4.0-alpha02"
const val BIOMETRICS = "androidx.biometric:biometric:1.2.0-alpha03"
const val CONSTRAINT_LAYOUT = "androidx.constraintlayout:constraintlayout:2.1.0-beta02"
const val CORE_KTX = "androidx.core:core-ktx:1.6.0-rc01"
const val FRAGMENT_KTX = "androidx.fragment:fragment-ktx:1.3.3"
const val CORE_KTX = "androidx.core:core-ktx:1.6.0"
const val FRAGMENT_KTX = "androidx.fragment:fragment-ktx:1.3.6"
const val LEGACY = "androidx.legacy:legacy-support-v4:1.0.0"
const val MULTIDEX = "androidx.multidex:multidex:2.0.1"
const val PAGING = "androidx.paging:paging-runtime-ktx:2.1.2"
@ -30,7 +30,7 @@ object Deps {
val CAMERA2 = "androidx.camera:camera-camera2:$version"
val CORE = "androidx.camera:camera-core:$version"
val LIFECYCLE = "androidx.camera:camera-lifecycle:$version"
object View : Version("1.0.0-alpha25") {
object View : Version("1.0.0-alpha27") {
val EXT = "androidx.camera:camera-extensions:$version"
val VIEW = "androidx.camera:camera-view:$version"
}
@ -74,6 +74,7 @@ object Deps {
}
object Kotlin : Version(kotlinVersion) {
val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version"
val REFLECT = "org.jetbrains.kotlin:kotlin-reflect:$version"
object Coroutines : Version("1.4.2") {
val ANDROID = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
val CORE = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
@ -96,10 +97,16 @@ object Deps {
object Test {
const val JUNIT = "junit:junit:4.13.2"
const val MOKITO = "org.mockito:mockito-android:3.11.1"
const val MOKITO = "org.mockito:mockito-android:3.12.4"
const val MOKITO_KOTLIN = "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
object Android {
const val JUNIT = "androidx.test.ext:junit:1.1.3-alpha06"
const val CORE = "androidx.test:core:1.4.0"
const val RULES = "androidx.test:rules:1.4.0"
const val JUNIT = "androidx.test.ext:junit:1.1.3"
const val FRAGMENT = "androidx.fragment:fragment-testing:1.4.0-alpha08"
const val ESPRESSO = "androidx.test.espresso:espresso-core:3.4.0"
const val ESPRESSO_INTENTS = "androidx.test.espresso:espresso-intents:3.4.0"
const val NAVIGATION = "androidx.navigation:navigation-testing:2.3.0-alpha01"
}
}
}

View File

@ -16,4 +16,8 @@ android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
android.useAndroidX=true
dagger.fastInit=enabled
android.builder.sdkDownload=true
android.builder.sdkDownload=true
# Optionally configures test orchestrator. While it provides isolated tests, it also nearly doubles
# the time it takes for test to run.
isUseTestOrchestrator=false