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

This commit is contained in:
Carter Jernigan 2023-01-26 14:12:44 -05:00 committed by GitHub
parent a5d38c6bf9
commit 6e85764f74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 756 additions and 138 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.crash.android.internal
package co.electriccoin.zcash.crash.android.internal.local
import android.os.Handler
import android.os.Looper

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

@ -10,6 +10,7 @@ import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.settings.view.Settings
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
@Composable
internal fun MainActivity.WrapSettings(
@ -30,13 +31,16 @@ private fun WrapSettings(
goWalletBackup: () -> Unit
) {
val walletViewModel by activity.viewModels<WalletViewModel>()
val settingsViewModel by activity.viewModels<SettingsViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val isAnalyticsEnabled = settingsViewModel.isAnalyticsEnabled.collectAsStateWithLifecycle().value
if (null == synchronizer) {
if (null == synchronizer || null == isAnalyticsEnabled) {
// Display loading indicator
} else {
Settings(
isAnalyticsEnabled,
onBack = goBack,
onBackupWallet = goWalletBackup,
onRescanWallet = {
@ -48,6 +52,9 @@ private fun WrapSettings(
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
onboardingViewModel.onboardingState.goToBeginning()
onboardingViewModel.isImporting.value = false
},
onAnalyticsSettingsChanged = {
settingsViewModel.setAnalyticsEnabled(it)
}
)
}

View File

@ -1,7 +1,12 @@
package co.electriccoin.zcash.ui.screen.settings.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@ -9,13 +14,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.TertiaryButton
@ -27,10 +39,12 @@ fun PreviewSettings() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Settings(
isAnalyticsEnabled = true,
onBack = {},
onBackupWallet = {},
onWipeWallet = {},
onRescanWallet = {}
onRescanWallet = {},
onAnalyticsSettingsChanged = {}
)
}
}
@ -38,20 +52,25 @@ fun PreviewSettings() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongParameterList")
fun Settings(
isAnalyticsEnabled: Boolean,
onBack: () -> Unit,
onBackupWallet: () -> Unit,
onWipeWallet: () -> Unit,
onRescanWallet: () -> Unit
onRescanWallet: () -> Unit,
onAnalyticsSettingsChanged: (Boolean) -> Unit
) {
Scaffold(topBar = {
SettingsTopAppBar(onBack = onBack)
}) { paddingValues ->
SettingsMainContent(
paddingValues,
isAnalyticsEnabled,
onBackupWallet = onBackupWallet,
onWipeWallet = onWipeWallet,
onRescanWallet = onRescanWallet
onRescanWallet = onRescanWallet,
onAnalyticsSettingsChanged = onAnalyticsSettingsChanged
)
}
}
@ -75,11 +94,14 @@ private fun SettingsTopAppBar(onBack: () -> Unit) {
}
@Composable
@Suppress("LongParameterList")
private fun SettingsMainContent(
paddingValues: PaddingValues,
isAnalyticsEnabled: Boolean,
onBackupWallet: () -> Unit,
@Suppress("UNUSED_PARAMETER") onWipeWallet: () -> Unit,
onRescanWallet: () -> Unit
onRescanWallet: () -> Unit,
onAnalyticsSettingsChanged: (Boolean) -> Unit
) {
Column(
Modifier
@ -89,5 +111,36 @@ private fun SettingsMainContent(
// We have decided to not include this in settings; see overflow debug menu instead
// DangerousButton(onClick = onWipeWallet, text = stringResource(id = R.string.settings_wipe))
TertiaryButton(onClick = onRescanWallet, text = stringResource(id = R.string.settings_rescan))
SwitchWithLabel(
label = stringResource(id = R.string.settings_enable_analytics),
state = isAnalyticsEnabled,
onStateChange = { onAnalyticsSettingsChanged(!isAnalyticsEnabled) }
)
}
}
@Composable
private fun SwitchWithLabel(label: String, state: Boolean, onStateChange: (Boolean) -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.clickable(
interactionSource = interactionSource,
indication = null, // disable ripple
role = Role.Switch,
onClick = { onStateChange(!state) }
)
.padding(8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Body(text = label)
Spacer(modifier = Modifier.fillMaxWidth(MINIMAL_WEIGHT))
Switch(
checked = state,
onCheckedChange = {
onStateChange(it)
}
)
}
}

View File

@ -0,0 +1,36 @@
package co.electriccoin.zcash.ui.screen.settings.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val mutex = Mutex()
val isAnalyticsEnabled: StateFlow<Boolean?> = flow<Boolean?> {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.IS_ANALYTICS_ENABLED.observe(preferenceProvider))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null)
fun setAnalyticsEnabled(enabled: Boolean) {
viewModelScope.launch {
val prefs = StandardPreferenceSingleton.getInstance(getApplication())
mutex.withLock {
// Note that the Application object observes this and performs the actual side effects
StandardPreferenceKeys.IS_ANALYTICS_ENABLED.putValue(prefs, enabled)
}
}
}
}

View File

@ -6,5 +6,6 @@
<string name="settings_wipe">Wipe Wallet Data</string>
<string name="settings_rescan">Rescan Blockchain</string>
<string name="settings_enable_analytics">Report crashes</string>
</resources>

View File

@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
import org.junit.Rule
import org.junit.Test
private const val DEFAULT_TIMEOUT_MILLISECONDS = 5_000L
private const val DEFAULT_TIMEOUT_MILLISECONDS = 10_000L
/*
* This screenshot implementation does not change the system-wide configuration, but rather