[#213] Contact support scaffold
This provides a basic screen for users to type a support inquiery, which then opens a pre-filled message in the user’s email app. There are a few followup issues: - Detect seed phrases in the email #377 - Report crashes in the generated email #378 - Fuzz timestamps #388 - Set the correct support email address #379 - Improve handling on devices without an email app #386
This commit is contained in:
parent
15f119371a
commit
48bd2e8ced
|
@ -231,7 +231,7 @@ jobs:
|
|||
workload_identity_provider: ${{ secrets.FIREBASE_TEST_LAB_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
access_token_lifetime: '1200s'
|
||||
- name: Test
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# This first environment variable is used by Flank, since the temporary token is missing the project name
|
||||
GOOGLE_CLOUD_PROJECT: ${{ secrets.FIREBASE_TEST_LAB_PROJECT }}
|
||||
|
|
|
@ -24,6 +24,7 @@ Contributions are very much welcomed! Please read our [Contributing Guidelines]
|
|||
If you plan to fork the project to create a new app of your own, please make the following changes. (If you're making a GitHub fork to contribute back to the project, these steps are not necessary.)
|
||||
|
||||
1. Change the app name under app/src/main/res/values/strings.xml
|
||||
1. Change the support email address under ui-lib/src/res/ui/support/values/strings.xml
|
||||
1. Remove any copyrighted ZCash or Electric Coin Company icons, logos, or assets
|
||||
1. ui-lib/src/main/res/common/ - All of the the ic_launcher assets
|
||||
1. Change the package name
|
||||
|
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.test.onChildren
|
|||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
||||
|
@ -176,6 +177,21 @@ class ScreenshotTest {
|
|||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
// Contact Support is a subscreen of profile
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.profile_support))).also {
|
||||
it.performScrollTo()
|
||||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
supportScreenshots(composeTestRule)
|
||||
|
||||
// Back to profile
|
||||
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.support_back_content_description))).also {
|
||||
it.assertExists()
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
// Back to home
|
||||
composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also {
|
||||
it.assertExists()
|
||||
|
@ -407,3 +423,11 @@ private fun sendZecScreenshots(composeTestRule: ComposeTestRule) {
|
|||
|
||||
ScreenshotTest.takeScreenshot("Send 2")
|
||||
}
|
||||
|
||||
private fun supportScreenshots(composeTestRule: ComposeTestRule) {
|
||||
composeTestRule.onNode(hasText(getStringResource(R.string.support_header))).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
ScreenshotTest.takeScreenshot("Support 1")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
Note: Contact Support will fail on some devices without an app to handle email, such as an Android TV device. See issue #386
|
||||
|
||||
# Check Support Email Contents
|
||||
1. If using a test device or emulator, be sure to configure a default email app. For example, try opening the Gmail app and confirm that it shows your inbox.
|
||||
1. Open the Zcash app
|
||||
1. Navigate to Profile
|
||||
1. Navigate to Support
|
||||
1. Type a message
|
||||
1. Choose send
|
||||
1. Choose OK
|
||||
1. Verify that the email app opens with a pre-filled message. The email subject should be "Zcash", the recipient should be the correct support email address, and the message body should include the message typed above, along with information about the user's current setup.
|
||||
|
||||
# Verify support
|
||||
1. If using a test device or emulator, be sure to configure a default email app. For example, try opening the Gmail app and confirm that it shows your inbox.
|
||||
1. Open the Zcash app
|
||||
1. Navigate to Profile
|
||||
1. Navigate to Support
|
||||
1. Type a message
|
||||
1. Choose send
|
||||
1. Choose OK
|
||||
1. After the email app opens, task switch back to the Zcash app
|
||||
1. Verify that you're returned to the Profile screen (specifically confirm the Support screen with the confirmation dialog is no longer on the screen)
|
|
@ -93,6 +93,7 @@ CORE_LIBRARY_DESUGARING_VERSION=1.1.5
|
|||
JACOCO_VERSION=0.8.8
|
||||
KOTLIN_VERSION=1.6.10
|
||||
KOTLINX_COROUTINES_VERSION=1.6.1
|
||||
KOTLINX_DATETIME_VERSION=0.3.2
|
||||
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
|
||||
ZCASH_BIP39_VERSION=1.0.2
|
||||
# TODO [#279]: Revert to stable SDK before app release
|
||||
|
|
|
@ -113,6 +113,7 @@ dependencyResolutionManagement {
|
|||
val jacocoVersion = extra["JACOCO_VERSION"].toString()
|
||||
val javaVersion = extra["ANDROID_JVM_TARGET"].toString()
|
||||
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
||||
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
|
||||
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
||||
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
|
||||
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
|
||||
|
@ -148,6 +149,7 @@ dependencyResolutionManagement {
|
|||
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion")
|
||||
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
|
||||
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
|
||||
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
|
||||
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
|
||||
library("zcash-bip39", "cash.z.ecc.android:kotlin-bip39:$zcashBip39Version")
|
||||
library("zcash-walletplgns", "cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version")
|
||||
|
|
|
@ -15,6 +15,9 @@ object AndroidApiVersion {
|
|||
return Build.VERSION.SDK_INT >= sdk
|
||||
}
|
||||
|
||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
|
||||
val isAtLeastN = isAtLeast(Build.VERSION_CODES.N)
|
||||
|
||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||
val isAtLeastO = isAtLeast(Build.VERSION_CODES.O)
|
||||
|
||||
|
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
|
@ -36,6 +40,7 @@ android {
|
|||
"src/main/res/ui/seed",
|
||||
"src/main/res/ui/send",
|
||||
"src/main/res/ui/settings",
|
||||
"src/main/res/ui/support",
|
||||
"src/main/res/ui/wallet_address"
|
||||
)
|
||||
)
|
||||
|
@ -44,6 +49,8 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.annotation)
|
||||
|
@ -56,10 +63,12 @@ dependencies {
|
|||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.zcash.sdk)
|
||||
implementation(libs.zcash.bip39)
|
||||
implementation(libs.zxing)
|
||||
|
||||
implementation(projects.buildInfoLib)
|
||||
implementation(projects.preferenceApiLib)
|
||||
implementation(projects.preferenceImplAndroidLib)
|
||||
implementation(projects.sdkExtLib)
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SupportInfoTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_time() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.timeInfo.toSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Time))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_app() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.appInfo.toSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.App))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_os() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.operatingSystemInfo.toSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Os))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_device() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.deviceInfo.toSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Device))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_crash() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.crashInfo.toCrashSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Crash))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_environment() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.environmentInfo.toSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Environment))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun filter_permission() = runTest {
|
||||
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
|
||||
|
||||
val individualExpected = supportInfo.permissionInfo.toPermissionSupportString()
|
||||
|
||||
val actualIncluded = supportInfo.toSupportString(setOf(SupportInfoType.Permission))
|
||||
assertTrue(actualIncluded.contains(individualExpected))
|
||||
|
||||
val actualExcluded = supportInfo.toSupportString(emptySet())
|
||||
assertFalse(actualExcluded.contains(individualExpected))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.util
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class EmailUtilTest {
|
||||
companion object {
|
||||
const val RECIPIENT = "foo@bar.com" // $NON-NLS
|
||||
const val SUBJECT = "ohai there" // $NON-NLS
|
||||
const val BODY = "i can haz cheezburger" // $NON-NLS
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun newMailToUriString() {
|
||||
val actual = EmailUtil.newMailToUriString(RECIPIENT, SUBJECT, BODY)
|
||||
val expected = "mailto:foo@bar.com?subject=ohai%20there&body=i%20can%20haz%20cheezburger"
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.view
|
||||
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
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.performTextInput
|
||||
import androidx.test.filters.MediumTest
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.test.getStringResource
|
||||
import co.electriccoin.zcash.ui.test.getStringResourceWithArgs
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class SupportViewIntegrationTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun message_state_restoration() {
|
||||
val restorationTester = StateRestorationTester(composeTestRule)
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
restorationTester.setContent {
|
||||
testSetup.getDefaultContent()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("I can haz cheezburger?").also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.support_hint)).also {
|
||||
it.performTextInput("I can haz cheezburger?")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("I can haz cheezburger?").also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
composeTestRule.onNodeWithText("I can haz cheezburger?").also {
|
||||
it.assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun dialog_state_restoration() {
|
||||
val restorationTester = StateRestorationTester(composeTestRule)
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
restorationTester.setContent {
|
||||
testSetup.getDefaultContent()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("I can haz cheezburger?").also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.support_send)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
val dialogContent = getStringResourceWithArgs(R.string.support_confirmation_explanation, getStringResource(R.string.app_name))
|
||||
composeTestRule.onNodeWithText(dialogContent).also {
|
||||
it.assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newTestSetup() = SupportViewTestSetup(composeTestRule)
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.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.performTextInput
|
||||
import androidx.test.filters.MediumTest
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.test.getStringResource
|
||||
import co.electriccoin.zcash.ui.test.getStringResourceWithArgs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class SupportViewTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
companion object {
|
||||
internal val DEFAULT_MESSAGE = "I can haz cheezburger?"
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun back() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
assertEquals(0, testSetup.getOnBackCount())
|
||||
|
||||
composeTestRule.clickBack()
|
||||
|
||||
assertEquals(1, testSetup.getOnBackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun send_shows_dialog() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
assertEquals(0, testSetup.getOnSendCount())
|
||||
assertEquals(null, testSetup.getSendMessage())
|
||||
|
||||
composeTestRule.typeMessage()
|
||||
composeTestRule.clickSend()
|
||||
|
||||
assertEquals(0, testSetup.getOnSendCount())
|
||||
|
||||
val dialogContent = getStringResourceWithArgs(R.string.support_confirmation_explanation, getStringResource(R.string.app_name))
|
||||
composeTestRule.onNodeWithText(dialogContent).also {
|
||||
it.assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun dialog_confirm_sends() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
assertEquals(0, testSetup.getOnSendCount())
|
||||
assertEquals(null, testSetup.getSendMessage())
|
||||
|
||||
composeTestRule.typeMessage()
|
||||
composeTestRule.clickSend()
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.support_confirmation_dialog_ok)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(1, testSetup.getOnSendCount())
|
||||
assertEquals(DEFAULT_MESSAGE, testSetup.getSendMessage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun dialog_cancel() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
assertEquals(0, testSetup.getOnSendCount())
|
||||
assertEquals(null, testSetup.getSendMessage())
|
||||
|
||||
composeTestRule.typeMessage()
|
||||
composeTestRule.clickSend()
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.support_confirmation_dialog_cancel)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
val dialogContent = getStringResourceWithArgs(R.string.support_confirmation_explanation, getStringResource(R.string.app_name))
|
||||
composeTestRule.onNodeWithText(dialogContent).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
assertEquals(0, testSetup.getOnSendCount())
|
||||
assertEquals(0, testSetup.getOnBackCount())
|
||||
}
|
||||
|
||||
private fun newTestSetup() = SupportViewTestSetup(composeTestRule).apply {
|
||||
setDefaultContent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ComposeContentTestRule.clickBack() {
|
||||
onNodeWithContentDescription(getStringResource(R.string.support_back_content_description)).also {
|
||||
it.performClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ComposeContentTestRule.clickSend() {
|
||||
onNodeWithContentDescription(getStringResource(R.string.support_send)).also {
|
||||
it.performClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ComposeContentTestRule.typeMessage() {
|
||||
onNodeWithText(getStringResource(R.string.support_hint)).also {
|
||||
it.performTextInput(SupportViewTest.DEFAULT_MESSAGE)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.view
|
||||
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class SupportViewTestSetup(private val composeTestRule: ComposeContentTestRule) {
|
||||
|
||||
private val onBackCount = AtomicInteger(0)
|
||||
|
||||
private val onSendCount = AtomicInteger(0)
|
||||
|
||||
private val onSendMessage = AtomicReference<String>(null)
|
||||
|
||||
fun getOnBackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onBackCount.get()
|
||||
}
|
||||
|
||||
fun getOnSendCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onSendCount.get()
|
||||
}
|
||||
|
||||
fun getSendMessage(): String? {
|
||||
composeTestRule.waitForIdle()
|
||||
return onSendMessage.get()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getDefaultContent() {
|
||||
Support(
|
||||
SnackbarHostState(),
|
||||
onBack = {
|
||||
onBackCount.incrementAndGet()
|
||||
},
|
||||
onSend = {
|
||||
onSendCount.incrementAndGet()
|
||||
onSendMessage.set(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun setDefaultContent() {
|
||||
composeTestRule.setContent {
|
||||
ZcashTheme {
|
||||
getDefaultContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,4 +6,4 @@ import androidx.test.core.app.ApplicationProvider
|
|||
|
||||
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)
|
||||
|
||||
fun getStringResourceWithArgs(@StringRes resId: Int, formatArgs: Array<Any>) = ApplicationProvider.getApplicationContext<Context>().getString(resId, *formatArgs)
|
||||
fun getStringResourceWithArgs(@StringRes resId: Int, vararg formatArgs: String) = ApplicationProvider.getApplicationContext<Context>().getString(resId, *formatArgs)
|
||||
|
|
|
@ -46,6 +46,7 @@ import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
|
|||
import co.electriccoin.zcash.ui.screen.seed.view.Seed
|
||||
import co.electriccoin.zcash.ui.screen.send.view.Send
|
||||
import co.electriccoin.zcash.ui.screen.settings.view.Settings
|
||||
import co.electriccoin.zcash.ui.screen.support.WrapSupport
|
||||
import co.electriccoin.zcash.ui.screen.wallet_address.view.WalletAddresses
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -216,7 +217,7 @@ class MainActivity : ComponentActivity() {
|
|||
onAddressBook = { },
|
||||
onSettings = { navController.navigate(NAV_SETTINGS) },
|
||||
onCoinholderVote = { },
|
||||
onSupport = {}
|
||||
onSupport = { navController.navigate(NAV_SUPPORT) }
|
||||
)
|
||||
}
|
||||
composable(NAV_WALLET_ADDRESS_DETAILS) {
|
||||
|
@ -249,6 +250,10 @@ class MainActivity : ComponentActivity() {
|
|||
composable(NAV_SEND) {
|
||||
WrapSend(goBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(NAV_SUPPORT) {
|
||||
// Pop back stack won't be right if we deep link into support
|
||||
WrapSupport(goBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -438,6 +443,9 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
@VisibleForTesting
|
||||
const val NAV_SEND = "send"
|
||||
|
||||
@VisibleForTesting
|
||||
const val NAV_SUPPORT = "support"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package co.electriccoin.zcash.ui.screen.support
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
|
||||
import co.electriccoin.zcash.ui.screen.support.model.SupportInfoType
|
||||
import co.electriccoin.zcash.ui.screen.support.util.EmailUtil
|
||||
import co.electriccoin.zcash.ui.screen.support.view.Support
|
||||
import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun MainActivity.WrapSupport(
|
||||
goBack: () -> Unit
|
||||
) {
|
||||
WrapSupport(this, goBack)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WrapSupport(
|
||||
activity: ComponentActivity,
|
||||
goBack: () -> Unit
|
||||
) {
|
||||
val viewModel by activity.viewModels<SupportViewModel>()
|
||||
val supportMessage = viewModel.supportInfo.collectAsState().value
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Support(
|
||||
snackbarHostState,
|
||||
onBack = goBack,
|
||||
onSend = { userMessage ->
|
||||
val fullMessage = formatMessage(userMessage, supportMessage)
|
||||
|
||||
val mailIntent = EmailUtil.newMailActivityIntent(
|
||||
activity.getString(R.string.support_email_address),
|
||||
activity.getString(R.string.app_name),
|
||||
fullMessage
|
||||
).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
|
||||
// TODO [#386] This should only fail if there's no email app, e.g. on a TV device
|
||||
runCatching {
|
||||
activity.startActivity(mailIntent)
|
||||
}.onSuccess {
|
||||
goBack()
|
||||
}.onFailure {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = activity.getString(R.string.support_unable_to_open_email)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Note that we don't need to localize this format string
|
||||
private fun formatMessage(
|
||||
messageBody: String,
|
||||
appInfo: SupportInfo?,
|
||||
supportInfoValues: Set<SupportInfoType> = SupportInfoType.values().toSet()
|
||||
): String = "$messageBody\n\n${appInfo?.toSupportString(supportInfoValues) ?: ""}"
|
|
@ -0,0 +1,20 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import co.electriccoin.zcash.build.gitSha
|
||||
import co.electriccoin.zcash.util.VersionCodeCompat
|
||||
|
||||
class AppInfo(val versionName: String, val versionCode: Long, val gitSha: String) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("App version: $versionName ($versionCode) $gitSha")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(packageInfo: PackageInfo) = AppInfo(
|
||||
packageInfo.versionName ?: "null", // Should only be null during tests
|
||||
VersionCodeCompat.getVersionCode(packageInfo),
|
||||
gitSha
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class CrashInfo(val timestamp: Instant, val isUncaught: Boolean, val className: String, val stacktrace: String) {
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Exception")
|
||||
appendLine(" Is uncaught: $isUncaught")
|
||||
appendLine(" Timestamp: $timestamp")
|
||||
appendLine(" Class name: $className")
|
||||
|
||||
// For now, don't include the stacktrace. It'll be too long for the emails we want to generate
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TODO [#303]: Implement returning some number of recent crashes
|
||||
suspend fun all(): List<CrashInfo> = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun List<CrashInfo>.toCrashSupportString() = buildString {
|
||||
// Using the header "Exceptions" instead of "Crashes" to reduce risk of alarming users
|
||||
appendLine("Exceptions:")
|
||||
this@toCrashSupportString.forEach {
|
||||
appendLine(it.toSupportString())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.os.Build
|
||||
|
||||
class DeviceInfo(val manufacturer: String, val device: String, val model: String) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Device: $manufacturer $device $model")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new() = DeviceInfo(Build.MANUFACTURER, Build.DEVICE, Build.MODEL)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.content.Context
|
||||
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
import java.util.Locale
|
||||
|
||||
class EnvironmentInfo(val locale: Locale, val monetarySeparators: MonetarySeparators) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Locale: ${locale.androidResName()}")
|
||||
appendLine("Currency grouping separator: ${monetarySeparators.grouping}")
|
||||
appendLine("Currency decimal separator: ${monetarySeparators.decimal}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(context: Context) = EnvironmentInfo(currentLocale(context), MonetarySeparators.current())
|
||||
|
||||
private fun currentLocale(context: Context) = if (AndroidApiVersion.isAtLeastN) {
|
||||
currentLocaleNPlus(context)
|
||||
} else {
|
||||
currentLocaleLegacy(context)
|
||||
}
|
||||
|
||||
private fun currentLocaleNPlus(context: Context) = context.resources.configuration.locales[0]
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun currentLocaleLegacy(context: Context) = context.resources.configuration.locale
|
||||
}
|
||||
}
|
||||
|
||||
private fun Locale.androidResName() = "$language-$country"
|
|
@ -0,0 +1,19 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.os.Build
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
|
||||
class OperatingSystemInfo(val sdkInt: Int, val isPreview: Boolean) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
if (isPreview) {
|
||||
appendLine("Android API: $sdkInt (preview)")
|
||||
} else {
|
||||
appendLine("Android API: $sdkInt")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new() = OperatingSystemInfo(Build.VERSION.SDK_INT, AndroidApiVersion.isPreview)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import co.electriccoin.zcash.util.myPackageInfo
|
||||
|
||||
class PermissionInfo(val permissionName: String, val permissionStatus: PermissionStatus) {
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("$permissionName $permissionStatus")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val permissionsOfInterest = listOf(Manifest.permission.CAMERA)
|
||||
|
||||
suspend fun all(context: Context): List<PermissionInfo> {
|
||||
val myPackageInfo: PackageInfo = context.myPackageInfo(PackageManager.GET_PERMISSIONS)
|
||||
|
||||
return permissionsOfInterest.map { new(context, myPackageInfo, it) }
|
||||
}
|
||||
|
||||
private fun new(context: Context, packageInfo: PackageInfo, permissionName: String): PermissionInfo {
|
||||
return if (isPermissionGrantedByUser(context, permissionName)) {
|
||||
PermissionInfo(permissionName, PermissionStatus.Granted)
|
||||
} else if (isPermissionGrantedByManifest(packageInfo, permissionName)) {
|
||||
PermissionInfo(permissionName, PermissionStatus.NotGrantedByUser)
|
||||
} else {
|
||||
PermissionInfo(permissionName, PermissionStatus.NotGrantedByManifest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPermissionGrantedByUser(context: Context, permissionName: String): Boolean {
|
||||
// Note: this is only checking very basic permissions
|
||||
// Some permissions, such as REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, require different checks
|
||||
return PackageManager.PERMISSION_GRANTED == context.checkSelfPermission(permissionName)
|
||||
}
|
||||
|
||||
private fun isPermissionGrantedByManifest(packageInfo: PackageInfo, permissionName: String): Boolean {
|
||||
return packageInfo.requestedPermissions?.any { permissionName == it } ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun List<PermissionInfo>.toPermissionSupportString() = buildString {
|
||||
if (this@toPermissionSupportString.isNotEmpty()) {
|
||||
appendLine("Permissions:")
|
||||
this@toPermissionSupportString.forEach {
|
||||
appendLine(it.toSupportString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class PermissionStatus {
|
||||
NotGrantedByManifest,
|
||||
NotGrantedByUser,
|
||||
Granted,
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.content.Context
|
||||
import co.electriccoin.zcash.util.myPackageInfo
|
||||
|
||||
enum class SupportInfoType {
|
||||
Time,
|
||||
App,
|
||||
Os,
|
||||
Device,
|
||||
Environment,
|
||||
Permission,
|
||||
Crash
|
||||
}
|
||||
|
||||
data class SupportInfo(
|
||||
val timeInfo: TimeInfo,
|
||||
val appInfo: AppInfo,
|
||||
val operatingSystemInfo: OperatingSystemInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
val environmentInfo: EnvironmentInfo,
|
||||
val permissionInfo: List<PermissionInfo>,
|
||||
val crashInfo: List<CrashInfo>
|
||||
) {
|
||||
|
||||
// The set of enum values is to allow optional filtering of different types of information
|
||||
// by users in the future. This would mostly be useful for using a web service request to post
|
||||
// instead of email (where users can edit the auto generated message)
|
||||
fun toSupportString(set: Set<SupportInfoType>) = buildString {
|
||||
if (set.contains(SupportInfoType.Time)) {
|
||||
append(timeInfo.toSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.App)) {
|
||||
append(appInfo.toSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.Os)) {
|
||||
append(operatingSystemInfo.toSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.Device)) {
|
||||
append(deviceInfo.toSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.Environment)) {
|
||||
append(environmentInfo.toSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.Permission)) {
|
||||
append(permissionInfo.toPermissionSupportString())
|
||||
}
|
||||
|
||||
if (set.contains(SupportInfoType.Crash)) {
|
||||
append(crashInfo.toCrashSupportString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Although most of our calls now are non-blocking, we expect more of them to be blocking
|
||||
// in the future.
|
||||
suspend fun new(context: Context): SupportInfo {
|
||||
val applicationContext = context.applicationContext
|
||||
val packageInfo = applicationContext.myPackageInfo(0)
|
||||
|
||||
return SupportInfo(
|
||||
TimeInfo.new(packageInfo),
|
||||
AppInfo.new(packageInfo),
|
||||
OperatingSystemInfo.new(),
|
||||
DeviceInfo.new(),
|
||||
EnvironmentInfo.new(applicationContext),
|
||||
PermissionInfo.all(applicationContext),
|
||||
CrashInfo.all()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.SystemClock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TimeInfo(
|
||||
val currentTime: Instant,
|
||||
val rebootTime: Instant,
|
||||
val installTime: Instant,
|
||||
val updateTime: Instant
|
||||
) {
|
||||
|
||||
// TODO [#388]: Consider fuzzing the times
|
||||
fun toSupportString() = buildString {
|
||||
// Use a slightly more human friendly format instead of ISO, since this will appear in the emails that users see
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd hh:mm:ss a", Locale.US) // $NON-NLS-1$
|
||||
|
||||
appendLine("Current time: ${dateFormat.formatInstant(currentTime)}")
|
||||
appendLine("Reboot time: ${dateFormat.formatInstant(rebootTime)}")
|
||||
appendLine("Install time: ${dateFormat.formatInstant(installTime)}")
|
||||
appendLine("Update time: ${dateFormat.formatInstant(updateTime)}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(packageInfo: PackageInfo): TimeInfo {
|
||||
val currentTime = Clock.System.now()
|
||||
val elapsedRealtime = SystemClock.elapsedRealtime().milliseconds
|
||||
|
||||
return TimeInfo(
|
||||
currentTime = currentTime,
|
||||
rebootTime = currentTime - elapsedRealtime,
|
||||
installTime = Instant.fromEpochMilliseconds(packageInfo.firstInstallTime),
|
||||
updateTime = Instant.fromEpochMilliseconds(packageInfo.lastUpdateTime),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SimpleDateFormat.formatInstant(instant: Instant) = format(Date(instant.toEpochMilliseconds()))
|
|
@ -0,0 +1,72 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
object EmailUtil {
|
||||
|
||||
/*
|
||||
* This mimetype is to hopefully ensure that only email apps respond to
|
||||
* the Intent. That isn't always the case though, as Evernote seems to
|
||||
* be greedy about Intents.
|
||||
*/
|
||||
private const val RFC2822_MIMETPYE = "message/rfc2822" // $NON-NLS$
|
||||
|
||||
/**
|
||||
* Note: the caller of this method may wish to set the Intent flag to
|
||||
* [Intent.FLAG_ACTIVITY_NEW_TASK].
|
||||
*
|
||||
* @param recipientAddress E-mail address of the recipient.
|
||||
* @param messageSubject Message subject.
|
||||
* @param messageBody Message body.
|
||||
* @return an Intent for launching the mail app with a pre-composed message.
|
||||
*/
|
||||
internal fun newMailActivityIntent(
|
||||
recipientAddress: String,
|
||||
messageSubject: String,
|
||||
messageBody: String
|
||||
): Intent = newIntentAsUri(recipientAddress, messageSubject, messageBody)
|
||||
|
||||
private fun newIntentAsUri(
|
||||
recipientAddress: String,
|
||||
messageSubject: String,
|
||||
messageBody: String
|
||||
) = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = Uri.parse(newMailToUriString(recipientAddress, messageSubject, messageBody))
|
||||
}
|
||||
|
||||
// After a discussion in ASG, this is probably the most robust
|
||||
// implementation instead of using the Intent extras like EXTRA_EMAIL, EXTRA_SUBJECT, etc.
|
||||
// https://medium.com/@cketti/android-sending-email-using-intents-3da63662c58f
|
||||
// This also does a reasonable job of only displaying legitimate email apps
|
||||
internal fun newMailToUriString(
|
||||
recipientAddress: String,
|
||||
messageSubject: String,
|
||||
messageBody: String
|
||||
): String {
|
||||
val encodedSubject = Uri.encode(messageSubject)
|
||||
val encodedBody = Uri.encode(messageBody)
|
||||
return "mailto:$recipientAddress?subject=$encodedSubject&body=$encodedBody" // $NON-NLS
|
||||
}
|
||||
|
||||
// This is a less correct, but reasonable alternative. Sometimes necessary to use this on
|
||||
// some buggy devices or buggy email app updates. Gmail had a bad update in 2019 for which this
|
||||
// was the best implementation
|
||||
internal fun newIntentAsExtras(
|
||||
recipientAddress: String,
|
||||
messageSubject: String,
|
||||
messageBody: String
|
||||
): Intent {
|
||||
return Intent(Intent.ACTION_SEND).apply {
|
||||
type = RFC2822_MIMETPYE
|
||||
putExtra(
|
||||
Intent.EXTRA_EMAIL,
|
||||
arrayOf(
|
||||
recipientAddress
|
||||
)
|
||||
)
|
||||
putExtra(Intent.EXTRA_SUBJECT, messageSubject)
|
||||
putExtra(Intent.EXTRA_TEXT, messageBody)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SmallTopAppBar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
|
||||
@Preview("Support")
|
||||
@Composable
|
||||
fun PreviewSupport() {
|
||||
ZcashTheme(darkTheme = true) {
|
||||
GradientSurface {
|
||||
Support(
|
||||
onBack = {},
|
||||
onSend = {},
|
||||
snackbarHostState = SnackbarHostState()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Support(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onBack: () -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
) {
|
||||
val (message, setMessage) = rememberSaveable { mutableStateOf("") }
|
||||
val (isShowingDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SupportTopAppBar(onBack = onBack)
|
||||
}, snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { setShowDialog(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Send,
|
||||
contentDescription = stringResource(id = R.string.support_send)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
SupportMainContent(message, setMessage)
|
||||
|
||||
if (isShowingDialog) {
|
||||
SupportConfirmationDialog(
|
||||
onConfirm = { onSend(message) },
|
||||
onDismiss = { setShowDialog(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportTopAppBar(onBack: () -> Unit) {
|
||||
SmallTopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.support_header)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.support_back_content_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportMainContent(
|
||||
message: String,
|
||||
setMessage: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
TextField(
|
||||
value = message,
|
||||
onValueChange = setMessage,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
label = { Text(text = stringResource(id = R.string.support_hint)) }
|
||||
)
|
||||
|
||||
Text(stringResource(id = R.string.support_disclaimer))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportConfirmationDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
) {
|
||||
Text(stringResource(id = R.string.support_confirmation_dialog_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(stringResource(id = R.string.support_confirmation_dialog_cancel))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(id = R.string.support_confirmation_explanation, stringResource(id = R.string.app_name)))
|
||||
},
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class SupportViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Technically, some of the support info could be invalidated after a configuration change,
|
||||
// such as the user's current locale. However it really doesn't matter here since all we
|
||||
// care about is capturing a snapshot of the app, OS, and device state.
|
||||
val supportInfo: StateFlow<SupportInfo?> = flow<SupportInfo?> { emit(SupportInfo.new(application)) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package co.electriccoin.zcash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* @return Current app's package info.
|
||||
*/
|
||||
suspend fun Context.myPackageInfo(flags: Int): PackageInfo {
|
||||
return try {
|
||||
withContext(Dispatchers.IO) { packageManager.getPackageInfo(packageName, flags) }
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
// The app's own package must exist, so this should never occur.
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package co.electriccoin.zcash.util
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
|
||||
object VersionCodeCompat {
|
||||
fun getVersionCode(packageInfo: PackageInfo): Long {
|
||||
return if (AndroidApiVersion.isAtLeastP) {
|
||||
getVersionCodePPlus(packageInfo)
|
||||
} else {
|
||||
getVersionCodeLegacy(packageInfo).toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun getVersionCodeLegacy(packageInfo: PackageInfo) = packageInfo.versionCode
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.P)
|
||||
private fun getVersionCodePPlus(packageInfo: PackageInfo) = packageInfo.longVersionCode
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="support_header">Support</string>
|
||||
<string name="support_back_content_description">Back</string>
|
||||
|
||||
<string name="support_hint">How can we help?</string>
|
||||
<string name="support_send">Send</string>
|
||||
<string name="support_confirmation_dialog_ok">OK</string>
|
||||
<string name="support_confirmation_dialog_cancel">Cancel</string>
|
||||
<string name="support_confirmation_explanation"><xliff:g id="app_name" example="Zcash">%1$s</xliff:g> is about to open your email app with a pre-filled message.\n\nBe sure to hit send within your email app.</string>
|
||||
<string name="support_email_address">support@electriccoin.co</string>
|
||||
<string name="support_disclaimer">Information provided is handled in accordance with our Privacy Policy.</string>
|
||||
<string name="support_unable_to_open_email">Unable to launch email app.</string>
|
||||
</resources>
|
Loading…
Reference in New Issue