[#346][#304] Configure Crashlytics and analytics opt-out

pull/739/head
Carter Jernigan 2 months ago committed by GitHub
parent a5d38c6bf9
commit 6e85764f74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      .github/workflows/deploy.yml
  2. 24
      .github/workflows/pull-request.yml
  3. 1
      .gitignore
  4. 1
      README.md
  5. 52
      app/build.gradle.kts
  6. 21
      app/src/main/java/co/electriccoin/zcash/app/CoroutineApplication.kt
  7. 41
      app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt
  8. 9
      build-conventions-secant/src/main/kotlin/secant.android-build-conventions.gradle.kts
  9. 39
      build.gradle.kts
  10. 4
      buildscript-gradle.lockfile
  11. 14
      crash-android-lib/build.gradle.kts
  12. 2
      crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandlerTest.kt
  13. 41
      crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/Components.kt
  14. 2
      crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/ReportableExceptionTest.kt
  15. 26
      crash-android-lib/src/main/AndroidManifest.xml
  16. 53
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/CrashReporter.kt
  17. 74
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/GlobalCrashReporter.kt
  18. 22
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashReporter.kt
  19. 38
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseAppCache.kt
  20. 129
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseCrashReporter.kt
  21. 4
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidExceptionReporter.kt
  22. 8
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidReportableException.kt
  23. 18
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandler.kt
  24. 2
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/CrashProcessNameContentProvider.kt
  25. 2
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/ExceptionReceiver.kt
  26. 48
      crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/LocalCrashReporter.kt
  27. 5
      crash-android-lib/src/main/res/values/bools.xml
  28. 4
      crash-android-lib/src/main/res/values/strings.xml
  29. 12
      docs/CI.md
  30. 9
      docs/testing/manual_testing/Crash Reporting.md
  31. 3
      gradle.properties
  32. 7
      sdk-ext-ui-lib/build.gradle.kts
  33. 4
      settings.gradle.kts
  34. 8
      spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt
  35. 7
      spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt
  36. 1
      ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt
  37. 6
      ui-lib/build.gradle.kts
  38. 24
      ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsViewTest.kt
  39. 3
      ui-lib/src/main/java/co/electriccoin/zcash/ui/preference/StandardPreferenceKeys.kt
  40. 4
      ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/view/HomeView.kt
  41. 9
      ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt
  42. 61
      ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt
  43. 36
      ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt
  44. 1
      ui-lib/src/main/res/ui/settings/values/strings.xml
  45. 2
      ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt

@ -6,6 +6,8 @@
# UPLOAD_KEYSTORE_PASSWORD - The password for UPLOAD_KEYSTORE_BASE_64
# UPLOAD_KEY_ALIAS - The key alias inside UPLOAD_KEYSTORE_BASE_64
# UPLOAD_KEY_ALIAS_PASSWORD - The password for the key alias
# FIREBASE_DEBUG_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for debug builds
# FIREBASE_RELEASE_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for release builds
name: Deploy
@ -70,6 +72,17 @@ jobs:
id: setup
timeout-minutes: 12
uses: ./.github/actions/setup
- name: Export Google Services JSON
env:
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
shell: bash
run: |
mkdir -p app/src/debug/
mkdir -p app/src/release/
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
- name: Authenticate to Google Cloud for Google Play
id: auth_google_play
uses: google-github-actions/auth@ef5d53e30bbcd8d0836f4288f5e50ff3e086997d

@ -2,6 +2,8 @@
# EMULATOR_WTF_API_KEY - Optional API key for emulator.wtf
# FIREBASE_TEST_LAB_SERVICE_ACCOUNT - Email address of Firebase Test Lab service account
# FIREBASE_TEST_LAB_WORKLOAD_IDENTITY_PROVIDER - Workload identity provider to generate temporary service account key
# FIREBASE_DEBUG_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for debug builds
# FIREBASE_RELEASE_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for release builds
# Expected variables
# FIREBASE_TEST_LAB_PROJECT - Firebase Test Lab project name
@ -392,6 +394,17 @@ jobs:
id: setup
timeout-minutes: 5
uses: ./.github/actions/setup
- name: Export Google Services JSON
env:
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
shell: bash
run: |
mkdir -p app/src/debug/
mkdir -p app/src/release/
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
- name: Build
timeout-minutes: 20
env:
@ -429,6 +442,17 @@ jobs:
id: setup
timeout-minutes: 5
uses: ./.github/actions/setup
- name: Export Google Services JSON
env:
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
shell: bash
run: |
mkdir -p app/src/debug/
mkdir -p app/src/release/
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
# A fake signing key to satisfy creating a "release" build
- name: Export Signing Key
env:

1
.gitignore vendored

@ -21,3 +21,4 @@ local.properties
/.idea/assetWizardSettings.xml
/.idea/inspectionProfiles/Project_Default.xml
/.idea/androidTestResultsUserPreferences.xml
google-services.json

@ -33,6 +33,7 @@ If you plan to fork the project to create a new app of your own, please make the
1. Under [app/build.gradle.kts](app/build.gradle.kts), change the package name of the application
1. Optional
1. Configure secrets for [Continuous Integration](docs/CI.md).
1. Configure Firebase API keys and placing them under `app/src/debug/google-services.json` and `app/src/release/google-services.json`
# Known Issues
1. Intel-based machines may have trouble building in Android Studio. The workaround is to add the following line to `~/.gradle/gradle.properties` `ZCASH_IS_DEPENDENCY_LOCKING_ENABLED=false`. See [#420](https://github.com/zcash/secant-android-wallet/issues/420) for more information.

@ -1,3 +1,5 @@
import com.android.build.api.variant.BuildConfigField
import com.android.build.api.variant.ResValue
import java.util.Locale
plugins {
@ -10,6 +12,26 @@ plugins {
id("secant.emulator-wtf-conventions")
}
val hasFirebaseApiKeys = run {
val srcDir = File(project.projectDir, "src")
val releaseApiKey = File(File(srcDir, "release"), "google-services.json")
val debugApiKey = File(File(srcDir, "debug"), "google-services.json")
val result = releaseApiKey.exists() && debugApiKey.exists()
if (!result) {
project.logger.info("Firebase API keys not found. Crashlytics will not be enabled. To enable " +
"Firebase, add the API keys for ${releaseApiKey.path} and ${debugApiKey.path}.")
}
result
}
if (hasFirebaseApiKeys) {
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
}
val packageName = project.property("ZCASH_RELEASE_PACKAGE_NAME").toString()
val testnetNetworkName = "Testnet"
@ -38,8 +60,8 @@ android {
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
buildFeatures {
buildConfig = true
}
flavorDimensions.add("network")
@ -112,13 +134,6 @@ android {
signingConfig = signingConfigs.getByName("debug")
}
}
all {
buildConfigField(
"boolean",
"IS_STRICT_MODE_CRASH_ENABLED",
project.property("IS_CRASH_ON_STRICT_MODE_VIOLATION").toString()
)
}
}
// Resolve final app name
@ -153,8 +168,6 @@ android {
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.androidx.activity)
implementation(libs.androidx.annotation)
implementation(libs.androidx.core)
@ -165,6 +178,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash.sdk) // just to configure logging
implementation(projects.crashAndroidLib)
implementation(projects.preferenceApiLib)
implementation(projects.preferenceImplAndroidLib)
implementation(projects.spackleAndroidLib)
implementation(projects.uiLib)
@ -190,6 +205,21 @@ val googlePlayServiceKeyFilePath = project.property("ZCASH_GOOGLE_PLAY_SERVICE_K
androidComponents {
onVariants { variant ->
for (output in variant.outputs) {
variant.buildConfigFields.put(
"IS_STRICT_MODE_CRASH_ENABLED",
BuildConfigField(
type = "boolean",
value = project.property("IS_CRASH_ON_STRICT_MODE_VIOLATION").toString(),
comment = null
)
)
variant.resValues.put(
// Key matches the one in crash-android-lib/src/res/values/bools.xml
variant.makeResValueKey("bool", "co_electriccoin_zcash_crash_is_firebase_enabled"),
ResValue(value = hasFirebaseApiKeys.toString())
)
if (googlePlayServiceKeyFilePath.isNotEmpty()) {
// Update the versionName to reflect bumps in versionCode

@ -0,0 +1,21 @@
package co.electriccoin.zcash.app
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
open class CoroutineApplication : Application() {
protected lateinit var applicationScope: CoroutineScope
override fun onCreate() {
super.onCreate()
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
override fun onTerminate() {
applicationScope.coroutineContext.cancel()
super.onTerminate()
}
}

@ -1,29 +1,58 @@
package co.electriccoin.zcash.app
import android.app.Application
import co.electriccoin.zcash.crash.android.CrashReporter
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.spackle.StrictModeCompat
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import kotlinx.coroutines.launch
@Suppress("unused")
class ZcashApplication : Application() {
class ZcashApplication : CoroutineApplication() {
override fun onCreate() {
super.onCreate()
configureLogging()
configureStrictMode()
// Since analytics will need disk IO internally, we want this to be registered after strict
// mode is configured to ensure none of that IO happens on the main thread
configureAnalytics()
}
private fun configureLogging() {
Twig.initialize(applicationContext)
Twig.info { "Starting application…" }
if (BuildConfig.DEBUG) {
StrictModeCompat.enableStrictMode(BuildConfig.IS_STRICT_MODE_CRASH_ENABLED)
// This is an internal API to the Zcash SDK to enable logging; it could change in the future
cash.z.ecc.android.sdk.internal.Twig.enabled(true)
} else {
// In release builds, logs should be stripped by R8 rules
Twig.assertLoggingStripped()
}
}
CrashReporter.register(this)
private fun configureStrictMode() {
if (BuildConfig.DEBUG) {
StrictModeCompat.enableStrictMode(BuildConfig.IS_STRICT_MODE_CRASH_ENABLED)
}
}
private fun configureAnalytics() {
if (GlobalCrashReporter.register(this)) {
applicationScope.launch {
val prefs = StandardPreferenceSingleton.getInstance(applicationContext)
StandardPreferenceKeys.IS_ANALYTICS_ENABLED.observe(prefs).collect {
if (it) {
GlobalCrashReporter.enable()
} else {
GlobalCrashReporter.disableAndDelete()
}
}
}
}
}
}

@ -89,6 +89,8 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension(isLibrary: Boo
ndkVersion = project.property("ANDROID_NDK_VERSION").toString()
compileOptions {
isCoreLibraryDesugaringEnabled = true
val javaVersion = JavaVersion.toVersion(project.property("ANDROID_JVM_TARGET").toString())
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
@ -184,6 +186,13 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension(isLibrary: Boo
freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn"
}
}
dependencies {
add(
"coreLibraryDesugaring",
"com.android.tools:desugar_jdk_libs:${project.property("CORE_LIBRARY_DESUGARING_VERSION")}"
)
}
}
fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {

@ -15,6 +15,45 @@ buildscript {
lockAllConfigurations()
}
}
repositories {
val isRepoRestrictionEnabled = true
val googleGroups = listOf<String>(
"com.google.firebase",
"com.google.gms",
"com.google.android.gms"
)
google {
if (isRepoRestrictionEnabled) {
content {
googleGroups.forEach { includeGroup(it) }
}
}
}
// We don't use mavenCentral now, but in the future we may want to use it for some dependencies
// mavenCentral {
// if (isRepoRestrictionEnabled) {
// content {
// googleGroups.forEach { excludeGroup(it) }
// }
// }
// }
gradlePluginPortal {
if (isRepoRestrictionEnabled) {
content {
googleGroups.forEach { excludeGroup(it) }
}
}
}
}
dependencies {
val crashlyticsVersion = project.property("FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION")
classpath("com.google.firebase:firebase-crashlytics-gradle:$crashlyticsVersion")
classpath("com.google.gms:google-services:${project.property("GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION")}")
}
}
plugins {

@ -43,6 +43,7 @@ com.android:signflinger:7.4.0=classpath
com.android:zipflinger:7.4.0=classpath
com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin:0.42.0=classpath,classpathCopy,classpathCopy2
com.github.ben-manes:gradle-versions-plugin:0.42.0=classpath
com.google.android.gms:strict-version-matcher-plugin:1.2.4=classpath
com.google.android:annotations:4.1.1.4=classpath
com.google.api.grpc:proto-google-common-protos:2.0.1=classpath
com.google.auto.value:auto-value-annotations:1.6.2=classpath
@ -51,7 +52,10 @@ com.google.code.gson:gson:2.8.9=classpath
com.google.crypto.tink:tink:1.3.0-rc2=classpath
com.google.dagger:dagger:2.28.3=classpath
com.google.errorprone:error_prone_annotations:2.4.0=classpath
com.google.firebase:firebase-crashlytics-buildtools:2.9.2=classpath
com.google.firebase:firebase-crashlytics-gradle:2.9.2=classpath
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
com.google.gms:google-services:4.3.15=classpath
com.google.guava:failureaccess:1.0.1=classpath
com.google.guava:guava:30.1-jre=classpath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath

@ -20,17 +20,17 @@ android {
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
api(projects.crashLib)
api(libs.androidx.annotation)
api(projects.crashLib)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.installations)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(projects.spackleAndroidLib)

@ -0,0 +1,41 @@
package co.electriccoin.zcash.crash.android.internal.local
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.spackle.AndroidApiVersion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class Components {
@Test
@SmallTest
fun process_names() {
val context = ApplicationProvider.getApplicationContext<Context>()
val pm = ApplicationProvider.getApplicationContext<Context>().packageManager
val providerInfo = pm.getProviderInfoCompat(ComponentName(context, CrashProcessNameContentProvider::class.java))
val receiverInfo = pm.getReceiverInfoCompat(ComponentName(context, ExceptionReceiver::class.java))
assertEquals(providerInfo.processName, receiverInfo.processName)
assertTrue(providerInfo.processName.endsWith(GlobalCrashReporter.CRASH_PROCESS_NAME_SUFFIX))
}
}
private fun PackageManager.getProviderInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) {
getProviderInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
} else {
@Suppress("Deprecation")
getProviderInfo(componentName, 0)
}
private fun PackageManager.getReceiverInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) {
getReceiverInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
} else {
@Suppress("Deprecation")
getReceiverInfo(componentName, 0)
}

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture

@ -1,20 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- We want better control over the timing of Firebase initialization -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name=".internal.local.CrashProcessNameContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:name="co.electriccoin.zcash.crash.android.internal.CrashProcessNameContentProvider"
android:exported="false"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:process="@string/co_electriccoin_zcash_crash_process_name_suffix"/>
android:exported="false"
android:process=":crash" />
<receiver
android:name="co.electriccoin.zcash.crash.android.internal.ExceptionReceiver"
android:exported="false"
android:name=".internal.local.ExceptionReceiver"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:process="@string/co_electriccoin_zcash_crash_process_name_suffix" />
android:exported="false"
android:process=":crash" />
</application>
</manifest>

@ -1,53 +0,0 @@
package co.electriccoin.zcash.crash.android
import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.android.internal.AndroidExceptionReporter
import co.electriccoin.zcash.crash.android.internal.AndroidUncaughtExceptionHandler
import co.electriccoin.zcash.crash.android.internal.new
import co.electriccoin.zcash.spackle.Twig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
object CrashReporter {
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Volatile
private var applicationContext: Context? = null
/**
* Call to register detection of uncaught exceptions.
*
* This must should only be called once for the entire lifetime of an application's process.
*/
@MainThread
fun register(context: Context) {
AndroidUncaughtExceptionHandler.register(context)
applicationContext = context.applicationContext
}
/**
* Report a caught exception, e.g. within a try-catch.
*
* Be sure to call [register] before calling this method.
*/
@AnyThread
fun reportCaughtException(exception: Throwable) {
// This method relies on a global Context reference, because often Context is not available
// in various places where we'd like to capture an exception from a try-catch.
applicationContext?.let {
crashReportingScope.launch {
AndroidExceptionReporter.reportException(it, ReportableException.new(it, exception, false))
}
} ?: run {
Twig.warn { "Unable to log exception; Call `register(Context)` prior to reportCaughtException(Throwable)" }
}
}
}

@ -0,0 +1,74 @@
package co.electriccoin.zcash.crash.android
import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.crash.android.internal.firebase.FirebaseCrashReporter
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.process.ProcessNameCompat
import java.util.Collections
object GlobalCrashReporter {
internal const val CRASH_PROCESS_NAME_SUFFIX = ":crash" // $NON-NLS
private val intrinsicLock = Any()
@Volatile
private var registeredCrashReporters: List<CrashReporter>? = null
/**
* Call to register detection of uncaught exceptions and enable reporting of caught exceptions.
*
* @return True if registration occurred and false if registration was skipped.
*/
@MainThread
fun register(context: Context): Boolean {
if (isCrashProcess(context)) {
Twig.debug { "Skipping registration for $CRASH_PROCESS_NAME_SUFFIX process" } // $NON-NLS
return false
}
synchronized(intrinsicLock) {
if (registeredCrashReporters == null) {
registeredCrashReporters = Collections.synchronizedList(
// To prevent a race condition, register the LocalCrashReporter first.
// FirebaseCrashReporter does some asynchronous registration internally, while
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
// and write the default UncaughtExceptionHandler. The only way to ensure
// interleaving doesn't happen is to register the LocalCrashReporter first.
listOfNotNull(
LocalCrashReporter.getInstance(context),
FirebaseCrashReporter(context),
)
)
}
}
return true
}
/**
* Report a caught exception, e.g. within a try-catch.
*
* Be sure to call [register] before calling this method.
*/
@AnyThread
fun reportCaughtException(exception: Throwable) {
registeredCrashReporters?.forEach { it.reportCaughtException(exception) }
}
fun disableAndDelete() {
registeredCrashReporters?.forEach { it.disableAndDelete() }
}
fun enable() {
registeredCrashReporters?.forEach { it.enable() }
}
}
private fun isCrashProcess(context: Context) =
ProcessNameCompat.getProcessName(context)
.endsWith(GlobalCrashReporter.CRASH_PROCESS_NAME_SUFFIX)

@ -0,0 +1,22 @@
package co.electriccoin.zcash.crash.android.internal
import androidx.annotation.AnyThread
interface CrashReporter {
/**
* Report a caught exception, e.g. within a try-catch.
*/
@AnyThread
fun reportCaughtException(exception: Throwable)
/**
* Enables crash reporting that may have privacy implications.
*/
fun enable()
/**
* Disables reporting and deletes any data that may have privacy implications.
*/
fun disableAndDelete()
}

@ -0,0 +1,38 @@
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import com.google.firebase.FirebaseApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object FirebaseAppCache {
private val mutex = Mutex()
@Volatile
private var cachedFirebaseApp: FirebaseAppContainer? = null
fun peekFirebaseApp(): FirebaseApp? = cachedFirebaseApp?.firebaseApp
suspend fun getFirebaseApp(context: Context): FirebaseApp? {
mutex.withLock {
peekFirebaseApp()?.let {
return it
}
val firebaseAppContainer = getFirebaseAppContainer(context)
cachedFirebaseApp = firebaseAppContainer
}
return peekFirebaseApp()
}
}
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer = withContext(Dispatchers.IO) {
val firebaseApp = FirebaseApp.initializeApp(context)
FirebaseAppContainer(firebaseApp)
}
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)

@ -0,0 +1,129 @@
@file:JvmName("FirebaseCrashReporterKt")
package co.electriccoin.zcash.crash.android.internal.firebase
import android.content.Context
import androidx.annotation.AnyThread
import co.electriccoin.zcash.crash.android.R
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.spackle.SuspendingLazy
import co.electriccoin.zcash.spackle.Twig
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.installations.FirebaseInstallations
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
/**
* Registers an exception handler with Firebase Crashlytics.
*/
internal class FirebaseCrashReporter(
context: Context
) : CrashReporter {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val initFirebaseJob: Deferred<CrashReporter?> = analyticsScope.async {
FirebaseCrashReporterImpl.getInstance(context)
}
@AnyThread
override fun reportCaughtException(exception: Throwable) {
initFirebaseJob.invokeOnCompletionWithResult {
it?.reportCaughtException(exception)
}
}
override fun enable() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.enable()
}
}
override fun disableAndDelete() {
initFirebaseJob.invokeOnCompletionWithResult {
it?.disableAndDelete()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.invokeOnCompletionWithResult(handler: (T) -> Unit) {
invokeOnCompletion {
handler(this.getCompleted())
}
}
/**
* Registers an exception handler with Firebase Crashlytics.
*/
private class FirebaseCrashReporterImpl(
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebaseInstallations: FirebaseInstallations
) : CrashReporter {
@AnyThread
override fun reportCaughtException(exception: Throwable) {
firebaseCrashlytics.recordException(exception)
}
override fun enable() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(true)
}
override fun disableAndDelete() {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(false)
firebaseCrashlytics.deleteUnsentReports()
firebaseInstallations.delete()
}
companion object {
/*
* Note there is a tradeoff with the suspending implementation. In order to avoid disk IO
* on the main thread, there is a brief timing gap during application startup where very
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
* ANRs.
*/
private val lazyWithArgument = SuspendingLazy<Context, CrashReporter?> {
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
// Workaround for disk IO on main thread in Firebase initialization
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
if (firebaseApp == null) {
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
return@SuspendingLazy null
}
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
val firebaseCrashlytics = FirebaseCrashlytics.getInstance().apply {
setCustomKey(
CrashlyticsUserProperties.IS_TEST,
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
)
}
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
} else {
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
null
}
}
suspend fun getInstance(context: Context): CrashReporter? {
return lazyWithArgument.getInstance(context)
}
}
}
internal object CrashlyticsUserProperties {
/**
* Flags a crash as occurring in a test environment. Set automatically to detect Firebase Test Lab and emulator.wtf
*/
const val IS_TEST = "is_test" // $NON-NLS
}

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import android.content.Context
import android.media.MediaScannerConnection
@ -7,7 +7,7 @@ import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.android.getExceptionPath
import co.electriccoin.zcash.crash.write
object AndroidExceptionReporter {
internal object AndroidExceptionReporter {
internal suspend fun reportException(context: Context, reportableException: ReportableException) {
val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException)
?: return

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import android.content.Context
import android.os.Bundle
@ -7,7 +7,7 @@ import co.electriccoin.zcash.spackle.getPackageInfoCompat
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
fun ReportableException.Companion.new(
internal fun ReportableException.Companion.new(
context: Context,
throwable: Throwable,
isUncaught: Boolean,
@ -25,7 +25,7 @@ fun ReportableException.Companion.new(
)
}
fun ReportableException.toBundle() = Bundle().apply {
internal fun ReportableException.toBundle() = Bundle().apply {
// Although Exception is Serializable, some Kotlin Coroutines exception classes break this
// API contract. Therefore we have to convert to a string here.
putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass)
@ -35,7 +35,7 @@ fun ReportableException.toBundle() = Bundle().apply {
putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds())
}
fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException {
internal fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException {
val className = bundle.getString(EXTRA_STRING_CLASS_NAME)!!
val trace = bundle.getString(EXTRA_STRING_TRACE)!!
val appVersion = bundle.getString(EXTRA_STRING_APP_VERSION)!!

@ -1,16 +1,14 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import android.content.Context
import android.os.Looper
import androidx.annotation.MainThread
import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.android.R
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.process.ProcessNameCompat
import kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicBoolean
class AndroidUncaughtExceptionHandler(
internal class AndroidUncaughtExceptionHandler(
context: Context,
private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
) : Thread.UncaughtExceptionHandler {
@ -44,17 +42,9 @@ class AndroidUncaughtExceptionHandler(
check(Looper.myLooper() == Looper.getMainLooper()) { "Must be called from the main thread" }
check(!isInitialized.getAndSet(true)) { "Uncaught exception handler can only be set once" }
if (isCrashProcess(context)) {
Twig.debug { "Uncaught exception handler will not be registered in the crash handling process" }
} else {
Thread.getDefaultUncaughtExceptionHandler()?.let { previous ->
Thread.setDefaultUncaughtExceptionHandler(AndroidUncaughtExceptionHandler(context, previous))
}
Thread.getDefaultUncaughtExceptionHandler()?.let { previous ->
Thread.setDefaultUncaughtExceptionHandler(AndroidUncaughtExceptionHandler(context, previous))
}
}
private fun isCrashProcess(context: Context) =
ProcessNameCompat.getProcessName(context)
.endsWith(context.getString(R.string.co_electriccoin_zcash_crash_process_name_suffix))
}
}

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import android.content.Context
import android.content.Intent

@ -0,0 +1,48 @@
package co.electriccoin.zcash.crash.android.internal.local
import android.content.Context
import androidx.annotation.AnyThread
import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.android.internal.CrashReporter
import co.electriccoin.zcash.spackle.LazyWithArgument
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Registers an exception handler to write exceptions to disk.
*/
internal class LocalCrashReporter(private val applicationContext: Context) : CrashReporter {
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@AnyThread
override fun reportCaughtException(exception: Throwable) {
crashReportingScope.launch {
AndroidExceptionReporter.reportException(
applicationContext,
ReportableException.new(applicationContext, exception, false)
)
}
}
override fun enable() {
// Noop, because there's no privacy implication for locally stored data
}
override fun disableAndDelete() {
// Noop, because there's no privacy implication for locally stored data
}
companion object {
private val lazyWithArgument = LazyWithArgument<Context, CrashReporter> {
AndroidUncaughtExceptionHandler.register(it)
LocalCrashReporter(it.applicationContext)
}
fun getInstance(context: Context): CrashReporter {
return lazyWithArgument.getInstance(context)
}
}
}

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
</resources>
<!-- Expected to be overridden by a resource overlay in the app module, generated
based on the presence of the Firebase API keys -->
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
</resources>

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="co_electriccoin_zcash_crash_process_name_suffix">:crash</string>
</resources>

@ -3,7 +3,7 @@ Continuous integration is set up with GitHub Actions. The workflows are defined
Workflows exist for:
* Pull request - On pull request, static analysis and testing is performed.
* Deploy - Work in progress. On merge to the main branch, a release build is automatically deployed. Concurrency limits are in place, to ensure that only one release deployment can happen at a time.
* Deploy - On merge to the main branch, a release build is automatically deployed. Concurrency limits are in place, to ensure that only one release deployment can happen at a time.
## Setup
When forking this repository, some variables/secrets need to be defined to set up new continuous integration builds.
@ -19,6 +19,8 @@ To enhance security, [OpenID Connect](https://docs.github.com/en/actions/deploym
* `EMULATOR_WTF_API_KEY` - API key for [Emulator.wtf](https://emulator.wtf)
* `FIREBASE_TEST_LAB_SERVICE_ACCOUNT` - Email address of Firebase Test Lab service account.
* `FIREBASE_TEST_LAB_WORKLOAD_IDENTITY_PROVIDER` - Workload identity provider to generate temporary service account key.
* `FIREBASE_DEBUG_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
* `FIREBASE_RELEASE_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
The Pull Request workflow supports testing of the app and libraries with both Emulator.wtf and Firebase Test Lab. By default, Emulator.wtf is used for library instrumentation tests, while Firebase Test Lab is used for a robo test.
@ -28,7 +30,9 @@ To configure Firebase Test Lab, you'll need to enable the necessary Google Cloud
Note that pull requests will create a "release" build with a temporary fake signing key. This simplifies configuration of CI for forks who simply want to run tests and not do release deployments. The limitations of this approach are:
- These builds cannot be used for testing of app upgrade compatibility (since signing key is different each time)
- Firebase, Google Play Services, and Google Maps won't work since they use the signing key to restrict API access. The app does not currently use these services, so this is a non-issue for now.
- Firebase, Google Play Services, and Google Maps won't work since they use the signing key to restrict API access. The app does not currently use any services with signature checks but this could become an issue in the future.
Note that `FIREBASE_DEBUG_JSON_BASE64` and `FIREBASE_RELEASE_JSON_BASE64` are not truly considered secret, as they contain API keys that are embedded in the application. However we are not including them in the repository to reduce accidental pollution of our crash report data from repository forks.
### Release deployment
* Secrets
@ -39,6 +43,8 @@ Note that pull requests will create a "release" build with a temporary fake sign
* `UPLOAD_KEYSTORE_PASSWORD` — Password for upload keystore.
* `UPLOAD_KEY_ALIAS` — Name of key inside upload keystore.
* `UPLOAD_KEY_ALIAS_PASSWORD` — Password for key alias.
* `FIREBASE_DEBUG_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
* `FIREBASE_RELEASE_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
To obtain the values for the Google Play deployment, you'll need to
@ -48,3 +54,5 @@ To obtain the values for the Google Play deployment, you'll need to
Note that security of release deployments is enhanced via two mechanisms:
- CI signs the app with the upload keystore and not the final release keystore. If the upload keystore is ever leaked, it can be rotated without impacting end user security.
- Deployment to Google Play can only be made to testing tracks. Release to production requires manual human login under a different account with greater permissions.
Note that `FIREBASE_DEBUG_JSON_BASE64` and `FIREBASE_RELEASE_JSON_BASE64` are not truly considered secret, as they contain API keys that are embedded in the application. However we are not including them in the repository to reduce accidental pollution of our crash report data from repository forks.

@ -9,5 +9,14 @@ The application will log crashes to external storage and can also include some i
6. Confirm that a new exception file exists in this directory
7. Repeat this with the "Throw Uncaught Exception" under the debug menu
# Crashes reported to Crashlytics
1. Compile a debug build of the app with Firebase API keys
1. Download Firebase JSON configuration files from https://console.firebase.google.com and place them in app/src/debug and app/src/release
1. OR download an APK built by GitHub Actions which has the API keys set up
1. Get past the onboarding to reach the Home screen
1. Under the debug menu, choose Report Caught Exception
1. Log onto the Firebase project and confirm the exception is reported
1. Repeat this with the "Throw Uncaught Exception" under the debug menu
# Crashes Reported in Contact Support
1. See the Contact Support test cases

@ -105,8 +105,10 @@ ANDROID_NDK_VERSION=23.0.7599858
ANDROID_GRADLE_PLUGIN_VERSION=7.4.0
DETEKT_VERSION=1.22.0
EMULATOR_WTF_GRADLE_PLUGIN_VERSION=0.0.15
FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION=2.9.2
FLANK_VERSION=23.01.0
FULLADLE_VERSION=0.17.4
GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15
GRADLE_VERSIONS_PLUGIN_VERSION=0.42.0
JGIT_VERSION=6.1.0.202203080745-r
KTLINT_VERSION=0.48.0
@ -139,6 +141,7 @@ ANDROIDX_TEST_SERVICE_VERSION=1.4.2
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
ANDROIDX_WORK_MANAGER_VERSION=2.7.1
CORE_LIBRARY_DESUGARING_VERSION=2.0.0
FIREBASE_BOM_VERSION_MATCHER=31.2.0
JACOCO_VERSION=0.8.8
KOTLIN_VERSION=1.8.0
KOTLINX_COROUTINES_VERSION=1.6.4

@ -10,14 +10,9 @@ android {
namespace = "cash.z.ecc.sdk.ext.ui"
testNamespace = "cash.z.ecc.sdk.ext.ui.test"
resourcePrefix = "co_electriccoin_zcash_"
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(projects.sdkExtLib)
implementation(libs.kotlinx.coroutines.core)
@ -39,4 +34,4 @@ dependencies {
}
}
}
}
}

@ -214,6 +214,10 @@ dependencyResolutionManagement {
library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
library("androidx-workmanager", "androidx.work:work-runtime-ktx:$androidxWorkManagerVersion")
library("desugaring", "com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
library("firebase-bom", "com.google.firebase:firebase-bom:${extra["FIREBASE_BOM_VERSION_MATCHER"]}")
library("firebase-installations", "com.google.firebase", "firebase-installations").withoutVersion()
library("firebase-crashlytics", "com.google.firebase", "firebase-crashlytics-ktx").withoutVersion()
library("firebase-crashlytics-ndk", "com.google.firebase", "firebase-crashlytics-ndk").withoutVersion()
library("kotlin-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
library("kotlin-reflect", "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion")

@ -8,6 +8,7 @@ import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Process
import androidx.annotation.RequiresApi
import co.electriccoin.zcash.spackle.AndroidApiVersion
@ -22,7 +23,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
override fun attachInfo(context: Context, info: ProviderInfo) {
super.attachInfo(context, info)
val processName: String = if (AndroidApiVersion.isAtLeastP) {
val processName: String = if (AndroidApiVersion.isAtLeastT) {
getProcessNameTPlus()
} else if (AndroidApiVersion.isAtLeastP) {
getProcessNamePPlus()
} else {
getProcessNameLegacy(context, info)
@ -31,6 +34,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
ProcessNameCompat.setProcessName(processName)
}
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
private fun getProcessNameTPlus() = Process.myProcessName()
@RequiresApi(api = Build.VERSION_CODES.P)
private fun getProcessNamePPlus(): String = Application.getProcessName()

@ -56,13 +56,18 @@ object ProcessNameCompat {
* due to some race conditions in Android.
*/
private fun searchForProcessName(context: Context): String? {
return if (AndroidApiVersion.isAtLeastP) {
return if (AndroidApiVersion.isAtLeastT) {
getProcessNameTPlus()
} else if (AndroidApiVersion.isAtLeastP) {
getProcessNamePPlus()
} else {
searchForProcessNameLegacy(context)
}
}
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
private fun getProcessNameTPlus() = Process.myProcessName()
@RequiresApi(api = Build.VERSION_CODES.P)
private fun getProcessNamePPlus() = Application.getProcessName()

@ -33,7 +33,6 @@ fun Body(
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start,
color: Color = MaterialTheme.colorScheme.onBackground
) {
Text(
text = text,

@ -13,10 +13,6 @@ android {
testInstrumentationRunner = "co.electriccoin.zcash.test.ZcashUiTestRunner"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
buildFeatures {
compose = true
}
@ -52,8 +48,6 @@ android {
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)

@ -65,6 +65,20 @@ class SettingsViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getRescanCount())
}
@Test
@MediumTest
fun toggle_analytics() = runTest {
val testSetup = TestSetup(composeTestRule)
assertEquals(0, testSetup.getBackupCount())
composeTestRule.onNodeWithText(getStringResource(R.string.settings_enable_analytics)).also {
it.performClick()
}
assertEquals(1, testSetup.getAnalyticsToggleCount())
}
@Test
@MediumTest
@Ignore("Wipe has been disabled in Settings and is now a debug-only option")
@ -86,6 +100,7 @@ class SettingsViewTest : UiTestPrerequisites() {
private val onBackupCount = AtomicInteger(0)
private val onRescanCount = AtomicInteger(0)
private val onWipeCount = AtomicInteger(0)
private val onAnalyticsChangedCount = AtomicInteger(0)
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
@ -107,10 +122,16 @@ class SettingsViewTest : UiTestPrerequisites() {
return onWipeCount.get()
}
fun getAnalyticsToggleCount(): Int {
composeTestRule.waitForIdle()
return onAnalyticsChangedCount.get()
}
init {
composeTestRule.setContent {
ZcashTheme {
Settings(
isAnalyticsEnabled = true,
onBack = {
onBackCount.incrementAndGet()
},
@ -122,6 +143,9 @@ class SettingsViewTest : UiTestPrerequisites() {
},
onWipeWallet = {
onWipeCount.incrementAndGet()
},
onAnalyticsSettingsChanged = {
onAnalyticsChangedCount.incrementAndGet()
}
)
}

@ -10,6 +10,9 @@ object StandardPreferenceKeys {
*/
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false)
// Default to true until https://github.com/zcash/secant-android-wallet/issues/304
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(Key("is_analytics_enabled"), true)
/**
* The fiat currency that the user prefers.
*/

@ -44,7 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.crash.android.CrashReporter
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
@ -153,7 +153,7 @@ private fun DebugMenu(resetSdk: () -> Unit, wipeEntireWallet: () -> Unit) {
onClick = {
// Eventually this shouldn't rely on the Android implementation, but rather an expect/actual
// should be used at the crash API level.
CrashReporter.reportCaughtException(RuntimeException("Manually caught exception from debug menu"))
GlobalCrashReporter.reportCaughtException(RuntimeException("Manually caught exception from debug menu"))
expanded = false
}
)