[#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:
Carter Jernigan 2022-04-19 21:28:49 -04:00 committed by GitHub
parent 15f119371a
commit 48bd2e8ced
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1101 additions and 3 deletions

View File

@ -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 }}

View File

@ -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

View File

@ -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")
}

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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()
}
}
}
}

View File

@ -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)

View File

@ -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"
}
}

View File

@ -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) ?: ""}"

View File

@ -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
)
}
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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"

View File

@ -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)
}
}

View File

@ -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,
}

View File

@ -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()
)
}
}
}

View File

@ -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()))

View File

@ -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)
}
}
}

View File

@ -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)))
},
)
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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>