diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c5e71517..787d19d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c1b6b834..ff6e2e83 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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: diff --git a/.gitignore b/.gitignore index 0e7888c4..07c28277 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ local.properties /.idea/assetWizardSettings.xml /.idea/inspectionProfiles/Project_Default.xml /.idea/androidTestResultsUserPreferences.xml +google-services.json diff --git a/README.md b/README.md index 5c183a0c..2d61d5dd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 07f34f17..9dddec4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/co/electriccoin/zcash/app/CoroutineApplication.kt b/app/src/main/java/co/electriccoin/zcash/app/CoroutineApplication.kt new file mode 100644 index 00000000..09f7dca0 --- /dev/null +++ b/app/src/main/java/co/electriccoin/zcash/app/CoroutineApplication.kt @@ -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() + } +} diff --git a/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt b/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt index 8be75904..c40dfa99 100644 --- a/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt +++ b/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt @@ -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() + } + } + } + } } } diff --git a/build-conventions-secant/src/main/kotlin/secant.android-build-conventions.gradle.kts b/build-conventions-secant/src/main/kotlin/secant.android-build-conventions.gradle.kts index cd96414f..0a207454 100644 --- a/build-conventions-secant/src/main/kotlin/secant.android-build-conventions.gradle.kts +++ b/build-conventions-secant/src/main/kotlin/secant.android-build-conventions.gradle.kts @@ -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) { diff --git a/build.gradle.kts b/build.gradle.kts index f7a1072f..2cd45a5f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,45 @@ buildscript { lockAllConfigurations() } } + + repositories { + val isRepoRestrictionEnabled = true + + val googleGroups = listOf( + "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 { diff --git a/buildscript-gradle.lockfile b/buildscript-gradle.lockfile index 1de7345d..89f8021d 100644 --- a/buildscript-gradle.lockfile +++ b/buildscript-gradle.lockfile @@ -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 diff --git a/crash-android-lib/build.gradle.kts b/crash-android-lib/build.gradle.kts index 8c7e8df5..81636e46 100644 --- a/crash-android-lib/build.gradle.kts +++ b/crash-android-lib/build.gradle.kts @@ -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) diff --git a/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandlerTest.kt b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandlerTest.kt similarity index 95% rename from crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandlerTest.kt rename to crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandlerTest.kt index 9dfcbcf9..ebd7e511 100644 --- a/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandlerTest.kt +++ b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandlerTest.kt @@ -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 diff --git a/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/Components.kt b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/Components.kt new file mode 100644 index 00000000..4f392879 --- /dev/null +++ b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/Components.kt @@ -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() + val pm = ApplicationProvider.getApplicationContext().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) +} diff --git a/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/ReportableExceptionTest.kt b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/ReportableExceptionTest.kt similarity index 89% rename from crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/ReportableExceptionTest.kt rename to crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/ReportableExceptionTest.kt index 412b16e9..62b96ee6 100644 --- a/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/ReportableExceptionTest.kt +++ b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/local/ReportableExceptionTest.kt @@ -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 diff --git a/crash-android-lib/src/main/AndroidManifest.xml b/crash-android-lib/src/main/AndroidManifest.xml index 2a0a6885..9698c610 100644 --- a/crash-android-lib/src/main/AndroidManifest.xml +++ b/crash-android-lib/src/main/AndroidManifest.xml @@ -1,20 +1,32 @@ - + + + + + + + + android:exported="false" + android:process=":crash" /> + android:exported="false" + android:process=":crash" /> diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/CrashReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/CrashReporter.kt deleted file mode 100644 index 4ff03078..00000000 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/CrashReporter.kt +++ /dev/null @@ -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)" } - } - } -} diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/GlobalCrashReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/GlobalCrashReporter.kt new file mode 100644 index 00000000..19bc657c --- /dev/null +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/GlobalCrashReporter.kt @@ -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? = 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) diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashReporter.kt new file mode 100644 index 00000000..d5d34ddd --- /dev/null +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashReporter.kt @@ -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() +} diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseAppCache.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseAppCache.kt new file mode 100644 index 00000000..974109f1 --- /dev/null +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseAppCache.kt @@ -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?) diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseCrashReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseCrashReporter.kt new file mode 100644 index 00000000..72d9ba0b --- /dev/null +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/firebase/FirebaseCrashReporter.kt @@ -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 = 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 Deferred.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 { + 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 +} diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidExceptionReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidExceptionReporter.kt similarity index 90% rename from crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidExceptionReporter.kt rename to crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidExceptionReporter.kt index eb93260a..ec0ad1ee 100644 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidExceptionReporter.kt +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidExceptionReporter.kt @@ -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 diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidReportableException.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidReportableException.kt similarity index 90% rename from crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidReportableException.kt rename to crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidReportableException.kt index 85a12f33..8a5afbe5 100644 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidReportableException.kt +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidReportableException.kt @@ -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)!! diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandler.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandler.kt similarity index 67% rename from crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandler.kt rename to crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandler.kt index 46e312ea..9a730939 100644 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandler.kt +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/AndroidUncaughtExceptionHandler.kt @@ -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)) } } diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashProcessNameContentProvider.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/CrashProcessNameContentProvider.kt similarity index 72% rename from crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashProcessNameContentProvider.kt rename to crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/CrashProcessNameContentProvider.kt index 14baed6c..ed89493f 100644 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashProcessNameContentProvider.kt +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/CrashProcessNameContentProvider.kt @@ -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 diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/ExceptionReceiver.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/ExceptionReceiver.kt similarity index 95% rename from crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/ExceptionReceiver.kt rename to crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/ExceptionReceiver.kt index d4bcd79d..91f06ea7 100644 --- a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/ExceptionReceiver.kt +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/ExceptionReceiver.kt @@ -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 diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/LocalCrashReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/LocalCrashReporter.kt new file mode 100644 index 00000000..e3e0e8e7 --- /dev/null +++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/local/LocalCrashReporter.kt @@ -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 { + AndroidUncaughtExceptionHandler.register(it) + LocalCrashReporter(it.applicationContext) + } + + fun getInstance(context: Context): CrashReporter { + return lazyWithArgument.getInstance(context) + } + } +} diff --git a/crash-android-lib/src/main/res/values/bools.xml b/crash-android-lib/src/main/res/values/bools.xml index 648e3cd9..0ee6dcc3 100644 --- a/crash-android-lib/src/main/res/values/bools.xml +++ b/crash-android-lib/src/main/res/values/bools.xml @@ -1,4 +1,7 @@ true - \ No newline at end of file + + false + diff --git a/crash-android-lib/src/main/res/values/strings.xml b/crash-android-lib/src/main/res/values/strings.xml deleted file mode 100644 index 446a83fe..00000000 --- a/crash-android-lib/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - :crash - diff --git a/docs/CI.md b/docs/CI.md index 668deeff..d774d010 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -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. \ No newline at end of file diff --git a/docs/testing/manual_testing/Crash Reporting.md b/docs/testing/manual_testing/Crash Reporting.md index 93008f88..e93b9be3 100644 --- a/docs/testing/manual_testing/Crash Reporting.md +++ b/docs/testing/manual_testing/Crash Reporting.md @@ -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 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8656ddaf..7768929d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/sdk-ext-ui-lib/build.gradle.kts b/sdk-ext-ui-lib/build.gradle.kts index 86f9e746..a7feed4b 100644 --- a/sdk-ext-ui-lib/build.gradle.kts +++ b/sdk-ext-ui-lib/build.gradle.kts @@ -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 { } } } -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 118a76c8..844c09cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt index b2c397ae..77aaade7 100644 --- a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt +++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt @@ -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() diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt index 16c4e983..2ef8952c 100644 --- a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt +++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt @@ -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() diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt index 86be16ba..c8f181f2 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt @@ -33,7 +33,6 @@ fun Body( modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start, color: Color = MaterialTheme.colorScheme.onBackground - ) { Text( text = text, diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 71259043..5c509c6e 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -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) diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsViewTest.kt index 0b19683d..7f4bf344 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsViewTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsViewTest.kt @@ -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() } ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/preference/StandardPreferenceKeys.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/preference/StandardPreferenceKeys.kt index 86a2e4c2..6d31de05 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/preference/StandardPreferenceKeys.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/preference/StandardPreferenceKeys.kt @@ -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. */ diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/view/HomeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/view/HomeView.kt index dfefd22d..76977378 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/view/HomeView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/view/HomeView.kt @@ -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 } ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt index ae2ae5d6..842a299d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt @@ -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() + val settingsViewModel by activity.viewModels() 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.onboardingState.goToBeginning() onboardingViewModel.isImporting.value = false + }, + onAnalyticsSettingsChanged = { + settingsViewModel.setAnalyticsEnabled(it) } ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt index be57730c..cf82bcdf 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt @@ -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) + } + ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..72e50a31 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt @@ -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 = flow { + 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) + } + } + } +} diff --git a/ui-lib/src/main/res/ui/settings/values/strings.xml b/ui-lib/src/main/res/ui/settings/values/strings.xml index 1066ca6f..32687e0a 100644 --- a/ui-lib/src/main/res/ui/settings/values/strings.xml +++ b/ui-lib/src/main/res/ui/settings/values/strings.xml @@ -6,5 +6,6 @@ Wipe Wallet Data Rescan Blockchain + Report crashes diff --git a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt index b4dedcec..d671ead9 100644 --- a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt +++ b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt @@ -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