[#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_KEYSTORE_PASSWORD - The password for UPLOAD_KEYSTORE_BASE_64
# UPLOAD_KEY_ALIAS - The key alias inside 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 # 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 name: Deploy
@ -70,6 +72,17 @@ jobs:
id: setup id: setup
timeout-minutes: 12 timeout-minutes: 12
uses: ./.github/actions/setup 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 - name: Authenticate to Google Cloud for Google Play
id: auth_google_play id: auth_google_play
uses: google-github-actions/auth@ef5d53e30bbcd8d0836f4288f5e50ff3e086997d uses: google-github-actions/auth@ef5d53e30bbcd8d0836f4288f5e50ff3e086997d

View File

@ -2,6 +2,8 @@
# EMULATOR_WTF_API_KEY - Optional API key for emulator.wtf # 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_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_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 # Expected variables
# FIREBASE_TEST_LAB_PROJECT - Firebase Test Lab project name # FIREBASE_TEST_LAB_PROJECT - Firebase Test Lab project name
@ -392,6 +394,17 @@ jobs:
id: setup id: setup
timeout-minutes: 5 timeout-minutes: 5
uses: ./.github/actions/setup 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 - name: Build
timeout-minutes: 20 timeout-minutes: 20
env: env:
@ -429,6 +442,17 @@ jobs:
id: setup id: setup
timeout-minutes: 5 timeout-minutes: 5
uses: ./.github/actions/setup 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 # A fake signing key to satisfy creating a "release" build
- name: Export Signing Key - name: Export Signing Key
env: env:

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ local.properties
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/inspectionProfiles/Project_Default.xml /.idea/inspectionProfiles/Project_Default.xml
/.idea/androidTestResultsUserPreferences.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. Under [app/build.gradle.kts](app/build.gradle.kts), change the package name of the application
1. Optional 1. Optional
1. Configure secrets for [Continuous Integration](docs/CI.md). 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 # 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. 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 import java.util.Locale
plugins { plugins {
@ -10,6 +12,26 @@ plugins {
id("secant.emulator-wtf-conventions") 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 packageName = project.property("ZCASH_RELEASE_PACKAGE_NAME").toString()
val testnetNetworkName = "Testnet" val testnetNetworkName = "Testnet"
@ -38,8 +60,8 @@ android {
} }
} }
compileOptions { buildFeatures {
isCoreLibraryDesugaringEnabled = true buildConfig = true
} }
flavorDimensions.add("network") flavorDimensions.add("network")
@ -112,13 +134,6 @@ android {
signingConfig = signingConfigs.getByName("debug") 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 // Resolve final app name
@ -153,8 +168,6 @@ android {
} }
dependencies { dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.androidx.activity) implementation(libs.androidx.activity)
implementation(libs.androidx.annotation) implementation(libs.androidx.annotation)
implementation(libs.androidx.core) implementation(libs.androidx.core)
@ -165,6 +178,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash.sdk) // just to configure logging implementation(libs.zcash.sdk) // just to configure logging
implementation(projects.crashAndroidLib) implementation(projects.crashAndroidLib)
implementation(projects.preferenceApiLib)
implementation(projects.preferenceImplAndroidLib)
implementation(projects.spackleAndroidLib) implementation(projects.spackleAndroidLib)
implementation(projects.uiLib) implementation(projects.uiLib)
@ -190,6 +205,21 @@ val googlePlayServiceKeyFilePath = project.property("ZCASH_GOOGLE_PLAY_SERVICE_K
androidComponents { androidComponents {
onVariants { variant -> onVariants { variant ->
for (output in variant.outputs) { 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()) { if (googlePlayServiceKeyFilePath.isNotEmpty()) {
// Update the versionName to reflect bumps in versionCode // 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 package co.electriccoin.zcash.app
import android.app.Application import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.crash.android.CrashReporter
import co.electriccoin.zcash.spackle.StrictModeCompat import co.electriccoin.zcash.spackle.StrictModeCompat
import co.electriccoin.zcash.spackle.Twig 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") @Suppress("unused")
class ZcashApplication : Application() { class ZcashApplication : CoroutineApplication() {
override fun onCreate() { override fun onCreate() {
super.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.initialize(applicationContext)
Twig.info { "Starting application…" } Twig.info { "Starting application…" }
if (BuildConfig.DEBUG) { 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 // 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) cash.z.ecc.android.sdk.internal.Twig.enabled(true)
} else { } else {
// In release builds, logs should be stripped by R8 rules // In release builds, logs should be stripped by R8 rules
Twig.assertLoggingStripped() 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() ndkVersion = project.property("ANDROID_NDK_VERSION").toString()
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
val javaVersion = JavaVersion.toVersion(project.property("ANDROID_JVM_TARGET").toString()) val javaVersion = JavaVersion.toVersion(project.property("ANDROID_JVM_TARGET").toString())
sourceCompatibility = javaVersion sourceCompatibility = javaVersion
targetCompatibility = javaVersion targetCompatibility = javaVersion
@ -184,6 +186,13 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension(isLibrary: Boo
freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn" 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) { fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {

View File

@ -15,6 +15,45 @@ buildscript {
lockAllConfigurations() 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 { plugins {

View File

@ -43,6 +43,7 @@ com.android:signflinger:7.4.0=classpath
com.android:zipflinger: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.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.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.android:annotations:4.1.1.4=classpath
com.google.api.grpc:proto-google-common-protos:2.0.1=classpath com.google.api.grpc:proto-google-common-protos:2.0.1=classpath
com.google.auto.value:auto-value-annotations:1.6.2=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.crypto.tink:tink:1.3.0-rc2=classpath
com.google.dagger:dagger:2.28.3=classpath com.google.dagger:dagger:2.28.3=classpath
com.google.errorprone:error_prone_annotations:2.4.0=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.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:failureaccess:1.0.1=classpath
com.google.guava:guava:30.1-jre=classpath com.google.guava:guava:30.1-jre=classpath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath

View File

@ -20,17 +20,17 @@ android {
testOptions { testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR" execution = "ANDROIDX_TEST_ORCHESTRATOR"
} }
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
} }
dependencies { dependencies {
coreLibraryDesugaring(libs.desugaring)
api(projects.crashLib)
api(libs.androidx.annotation) 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.coroutines.core)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(projects.spackleAndroidLib) 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.Handler
import android.os.Looper 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.ReportableException
import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture

View File

@ -1,20 +1,32 @@
<?xml version="1.0" encoding="utf-8"?> <?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> <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 <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: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: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 <receiver
android:name="co.electriccoin.zcash.crash.android.internal.ExceptionReceiver" android:name=".internal.local.ExceptionReceiver"
android:exported="false"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process" 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> </application>
</manifest> </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.content.Context
import android.media.MediaScannerConnection 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.android.getExceptionPath
import co.electriccoin.zcash.crash.write import co.electriccoin.zcash.crash.write
object AndroidExceptionReporter { internal object AndroidExceptionReporter {
internal suspend fun reportException(context: Context, reportableException: ReportableException) { internal suspend fun reportException(context: Context, reportableException: ReportableException) {
val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException) val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException)
?: return ?: 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.content.Context
import android.os.Bundle import android.os.Bundle
@ -7,7 +7,7 @@ import co.electriccoin.zcash.spackle.getPackageInfoCompat
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
fun ReportableException.Companion.new( internal fun ReportableException.Companion.new(
context: Context, context: Context,
throwable: Throwable, throwable: Throwable,
isUncaught: Boolean, 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 // Although Exception is Serializable, some Kotlin Coroutines exception classes break this
// API contract. Therefore we have to convert to a string here. // API contract. Therefore we have to convert to a string here.
putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass) putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass)
@ -35,7 +35,7 @@ fun ReportableException.toBundle() = Bundle().apply {
putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds()) 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 className = bundle.getString(EXTRA_STRING_CLASS_NAME)!!
val trace = bundle.getString(EXTRA_STRING_TRACE)!! val trace = bundle.getString(EXTRA_STRING_TRACE)!!
val appVersion = bundle.getString(EXTRA_STRING_APP_VERSION)!! 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.content.Context
import android.os.Looper import android.os.Looper
import androidx.annotation.MainThread import androidx.annotation.MainThread
import co.electriccoin.zcash.crash.ReportableException import co.electriccoin.zcash.crash.ReportableException
import co.electriccoin.zcash.crash.android.R 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 kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class AndroidUncaughtExceptionHandler( internal class AndroidUncaughtExceptionHandler(
context: Context, context: Context,
private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
) : Thread.UncaughtExceptionHandler { ) : Thread.UncaughtExceptionHandler {
@ -44,17 +42,9 @@ class AndroidUncaughtExceptionHandler(
check(Looper.myLooper() == Looper.getMainLooper()) { "Must be called from the main thread" } check(Looper.myLooper() == Looper.getMainLooper()) { "Must be called from the main thread" }
check(!isInitialized.getAndSet(true)) { "Uncaught exception handler can only be set once" } check(!isInitialized.getAndSet(true)) { "Uncaught exception handler can only be set once" }
if (isCrashProcess(context)) { Thread.getDefaultUncaughtExceptionHandler()?.let { previous ->
Twig.debug { "Uncaught exception handler will not be registered in the crash handling process" } Thread.setDefaultUncaughtExceptionHandler(AndroidUncaughtExceptionHandler(context, previous))
} else {
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 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.Context
import android.content.Intent 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool> <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: Workflows exist for:
* Pull request - On pull request, static analysis and testing is performed. * 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 ## Setup
When forking this repository, some variables/secrets need to be defined to set up new continuous integration builds. 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) * `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_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_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. 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: 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) - 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 ### Release deployment
* Secrets * 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_KEYSTORE_PASSWORD` — Password for upload keystore.
* `UPLOAD_KEY_ALIAS` — Name of key inside upload keystore. * `UPLOAD_KEY_ALIAS` — Name of key inside upload keystore.
* `UPLOAD_KEY_ALIAS_PASSWORD` — Password for key alias. * `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 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: 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. - 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. - 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 6. Confirm that a new exception file exists in this directory
7. Repeat this with the "Throw Uncaught Exception" under the debug menu 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 # Crashes Reported in Contact Support
1. See the Contact Support test cases 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 ANDROID_GRADLE_PLUGIN_VERSION=7.4.0
DETEKT_VERSION=1.22.0 DETEKT_VERSION=1.22.0
EMULATOR_WTF_GRADLE_PLUGIN_VERSION=0.0.15 EMULATOR_WTF_GRADLE_PLUGIN_VERSION=0.0.15
FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION=2.9.2
FLANK_VERSION=23.01.0 FLANK_VERSION=23.01.0
FULLADLE_VERSION=0.17.4 FULLADLE_VERSION=0.17.4
GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15
GRADLE_VERSIONS_PLUGIN_VERSION=0.42.0 GRADLE_VERSIONS_PLUGIN_VERSION=0.42.0
JGIT_VERSION=6.1.0.202203080745-r JGIT_VERSION=6.1.0.202203080745-r
KTLINT_VERSION=0.48.0 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_UI_AUTOMATOR_VERSION=2.2.0-alpha1
ANDROIDX_WORK_MANAGER_VERSION=2.7.1 ANDROIDX_WORK_MANAGER_VERSION=2.7.1
CORE_LIBRARY_DESUGARING_VERSION=2.0.0 CORE_LIBRARY_DESUGARING_VERSION=2.0.0
FIREBASE_BOM_VERSION_MATCHER=31.2.0
JACOCO_VERSION=0.8.8 JACOCO_VERSION=0.8.8
KOTLIN_VERSION=1.8.0 KOTLIN_VERSION=1.8.0
KOTLINX_COROUTINES_VERSION=1.6.4 KOTLINX_COROUTINES_VERSION=1.6.4

View File

@ -10,14 +10,9 @@ android {
namespace = "cash.z.ecc.sdk.ext.ui" namespace = "cash.z.ecc.sdk.ext.ui"
testNamespace = "cash.z.ecc.sdk.ext.ui.test" testNamespace = "cash.z.ecc.sdk.ext.ui.test"
resourcePrefix = "co_electriccoin_zcash_" resourcePrefix = "co_electriccoin_zcash_"
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
} }
dependencies { dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(projects.sdkExtLib) implementation(projects.sdkExtLib)
implementation(libs.kotlinx.coroutines.core) 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-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
library("androidx-workmanager", "androidx.work:work-runtime-ktx:$androidxWorkManagerVersion") library("androidx-workmanager", "androidx.work:work-runtime-ktx:$androidxWorkManagerVersion")
library("desugaring", "com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion") 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-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
library("kotlin-reflect", "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") library("kotlin-reflect", "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$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.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Process
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import co.electriccoin.zcash.spackle.AndroidApiVersion import co.electriccoin.zcash.spackle.AndroidApiVersion
@ -22,7 +23,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
override fun attachInfo(context: Context, info: ProviderInfo) { override fun attachInfo(context: Context, info: ProviderInfo) {
super.attachInfo(context, info) super.attachInfo(context, info)
val processName: String = if (AndroidApiVersion.isAtLeastP) { val processName: String = if (AndroidApiVersion.isAtLeastT) {
getProcessNameTPlus()
} else if (AndroidApiVersion.isAtLeastP) {
getProcessNamePPlus() getProcessNamePPlus()
} else { } else {
getProcessNameLegacy(context, info) getProcessNameLegacy(context, info)
@ -31,6 +34,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
ProcessNameCompat.setProcessName(processName) ProcessNameCompat.setProcessName(processName)
} }
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
private fun getProcessNameTPlus() = Process.myProcessName()
@RequiresApi(api = Build.VERSION_CODES.P) @RequiresApi(api = Build.VERSION_CODES.P)
private fun getProcessNamePPlus(): String = Application.getProcessName() private fun getProcessNamePPlus(): String = Application.getProcessName()

View File

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

View File

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

View File

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

View File

@ -65,6 +65,20 @@ class SettingsViewTest : UiTestPrerequisites() {
assertEquals(1, testSetup.getRescanCount()) 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 @Test
@MediumTest @MediumTest
@Ignore("Wipe has been disabled in Settings and is now a debug-only option") @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 onBackupCount = AtomicInteger(0)
private val onRescanCount = AtomicInteger(0) private val onRescanCount = AtomicInteger(0)
private val onWipeCount = AtomicInteger(0) private val onWipeCount = AtomicInteger(0)
private val onAnalyticsChangedCount = AtomicInteger(0)
fun getOnBackCount(): Int { fun getOnBackCount(): Int {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
@ -107,10 +122,16 @@ class SettingsViewTest : UiTestPrerequisites() {
return onWipeCount.get() return onWipeCount.get()
} }
fun getAnalyticsToggleCount(): Int {
composeTestRule.waitForIdle()
return onAnalyticsChangedCount.get()
}
init { init {
composeTestRule.setContent { composeTestRule.setContent {
ZcashTheme { ZcashTheme {
Settings( Settings(
isAnalyticsEnabled = true,
onBack = { onBack = {
onBackCount.incrementAndGet() onBackCount.incrementAndGet()
}, },
@ -122,6 +143,9 @@ class SettingsViewTest : UiTestPrerequisites() {
}, },
onWipeWallet = { onWipeWallet = {
onWipeCount.incrementAndGet() 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) 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. * 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 androidx.compose.ui.unit.dp
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.model.PercentDecimal 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.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body import co.electriccoin.zcash.ui.design.component.Body
@ -153,7 +153,7 @@ private fun DebugMenu(resetSdk: () -> Unit, wipeEntireWallet: () -> Unit) {
onClick = { onClick = {
// Eventually this shouldn't rely on the Android implementation, but rather an expect/actual // Eventually this shouldn't rely on the Android implementation, but rather an expect/actual
// should be used at the crash API level. // 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 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.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel 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.view.Settings
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
@Composable @Composable
internal fun MainActivity.WrapSettings( internal fun MainActivity.WrapSettings(
@ -30,13 +31,16 @@ private fun WrapSettings(
goWalletBackup: () -> Unit goWalletBackup: () -> Unit
) { ) {
val walletViewModel by activity.viewModels<WalletViewModel>() val walletViewModel by activity.viewModels<WalletViewModel>()
val settingsViewModel by activity.viewModels<SettingsViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val isAnalyticsEnabled = settingsViewModel.isAnalyticsEnabled.collectAsStateWithLifecycle().value
if (null == synchronizer) { if (null == synchronizer || null == isAnalyticsEnabled) {
// Display loading indicator // Display loading indicator
} else { } else {
Settings( Settings(
isAnalyticsEnabled,
onBack = goBack, onBack = goBack,
onBackupWallet = goWalletBackup, onBackupWallet = goWalletBackup,
onRescanWallet = { onRescanWallet = {
@ -48,6 +52,9 @@ private fun WrapSettings(
val onboardingViewModel by activity.viewModels<OnboardingViewModel>() val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
onboardingViewModel.onboardingState.goToBeginning() onboardingViewModel.onboardingState.goToBeginning()
onboardingViewModel.isImporting.value = false onboardingViewModel.isImporting.value = false
},
onAnalyticsSettingsChanged = {
settingsViewModel.setAnalyticsEnabled(it)
} }
) )
} }

View File

@ -1,7 +1,12 @@
package co.electriccoin.zcash.ui.screen.settings.view 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.Column
import androidx.compose.foundation.layout.PaddingValues 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.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R 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.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.TertiaryButton import co.electriccoin.zcash.ui.design.component.TertiaryButton
@ -27,10 +39,12 @@ fun PreviewSettings() {
ZcashTheme(darkTheme = true) { ZcashTheme(darkTheme = true) {
GradientSurface { GradientSurface {
Settings( Settings(
isAnalyticsEnabled = true,
onBack = {}, onBack = {},
onBackupWallet = {}, onBackupWallet = {},
onWipeWallet = {}, onWipeWallet = {},
onRescanWallet = {} onRescanWallet = {},
onAnalyticsSettingsChanged = {}
) )
} }
} }
@ -38,20 +52,25 @@ fun PreviewSettings() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Suppress("LongParameterList")
fun Settings( fun Settings(
isAnalyticsEnabled: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onBackupWallet: () -> Unit, onBackupWallet: () -> Unit,
onWipeWallet: () -> Unit, onWipeWallet: () -> Unit,
onRescanWallet: () -> Unit onRescanWallet: () -> Unit,
onAnalyticsSettingsChanged: (Boolean) -> Unit
) { ) {
Scaffold(topBar = { Scaffold(topBar = {
SettingsTopAppBar(onBack = onBack) SettingsTopAppBar(onBack = onBack)
}) { paddingValues -> }) { paddingValues ->
SettingsMainContent( SettingsMainContent(
paddingValues, paddingValues,
isAnalyticsEnabled,
onBackupWallet = onBackupWallet, onBackupWallet = onBackupWallet,
onWipeWallet = onWipeWallet, onWipeWallet = onWipeWallet,
onRescanWallet = onRescanWallet onRescanWallet = onRescanWallet,
onAnalyticsSettingsChanged = onAnalyticsSettingsChanged
) )
} }
} }
@ -75,11 +94,14 @@ private fun SettingsTopAppBar(onBack: () -> Unit) {
} }
@Composable @Composable
@Suppress("LongParameterList")
private fun SettingsMainContent( private fun SettingsMainContent(
paddingValues: PaddingValues, paddingValues: PaddingValues,
isAnalyticsEnabled: Boolean,
onBackupWallet: () -> Unit, onBackupWallet: () -> Unit,
@Suppress("UNUSED_PARAMETER") onWipeWallet: () -> Unit, @Suppress("UNUSED_PARAMETER") onWipeWallet: () -> Unit,
onRescanWallet: () -> Unit onRescanWallet: () -> Unit,
onAnalyticsSettingsChanged: (Boolean) -> Unit
) { ) {
Column( Column(
Modifier Modifier
@ -89,5 +111,36 @@ private fun SettingsMainContent(
// We have decided to not include this in settings; see overflow debug menu instead // We have decided to not include this in settings; see overflow debug menu instead
// DangerousButton(onClick = onWipeWallet, text = stringResource(id = R.string.settings_wipe)) // DangerousButton(onClick = onWipeWallet, text = stringResource(id = R.string.settings_wipe))
TertiaryButton(onClick = onRescanWallet, text = stringResource(id = R.string.settings_rescan)) 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_wipe">Wipe Wallet Data</string>
<string name="settings_rescan">Rescan Blockchain</string> <string name="settings_rescan">Rescan Blockchain</string>
<string name="settings_enable_analytics">Report crashes</string>
</resources> </resources>

View File

@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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 * This screenshot implementation does not change the system-wide configuration, but rather