diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index ca70db67..24052f5b 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -196,7 +196,7 @@ jobs:
timeout-minutes: 4
run: |
# Note that we explicitly check just the Kotlin modules, to avoid compiling the Android modules here
- ./gradlew :preference-api-lib:check
+ ./gradlew :crash-lib:check :preference-api-lib:check :spackle-lib:check
- name: Collect Artifacts
if: ${{ always() }}
timeout-minutes: 1
diff --git a/.idea/runConfigurations/assemble.xml b/.idea/runConfigurations/assemble.xml
index 8f9115b9..451a6c5b 100644
--- a/.idea/runConfigurations/assemble.xml
+++ b/.idea/runConfigurations/assemble.xml
@@ -4,12 +4,14 @@
-
+
diff --git a/.idea/runConfigurations/assembleAndroidTest.xml b/.idea/runConfigurations/assembleAndroidTest.xml
index 0e9472f4..1996b683 100644
--- a/.idea/runConfigurations/assembleAndroidTest.xml
+++ b/.idea/runConfigurations/assembleAndroidTest.xml
@@ -4,12 +4,14 @@
-
+
-
+
+
+
diff --git a/.idea/runConfigurations/kotlin_test.xml b/.idea/runConfigurations/check.xml
similarity index 77%
rename from .idea/runConfigurations/kotlin_test.xml
rename to .idea/runConfigurations/check.xml
index 61be4aa1..2930ffda 100644
--- a/.idea/runConfigurations/kotlin_test.xml
+++ b/.idea/runConfigurations/check.xml
@@ -1,5 +1,5 @@
-
+
@@ -10,10 +10,10 @@
-
+
-
+
true
true
diff --git a/.idea/runConfigurations/check_connectedCheck_detektAll_ktlint_lint.xml b/.idea/runConfigurations/check_connectedCheck_detektAll_ktlint_lint.xml
new file mode 100644
index 00000000..231fa8e3
--- /dev/null
+++ b/.idea/runConfigurations/check_connectedCheck_detektAll_ktlint_lint.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/connectedCheck.xml b/.idea/runConfigurations/connectedCheck.xml
new file mode 100644
index 00000000..87cadcb9
--- /dev/null
+++ b/.idea/runConfigurations/connectedCheck.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/spackle_lib_connectedCheck.xml b/.idea/runConfigurations/crash_android_lib_connectedCheck.xml
similarity index 88%
rename from .idea/runConfigurations/spackle_lib_connectedCheck.xml
rename to .idea/runConfigurations/crash_android_lib_connectedCheck.xml
index cccebefd..72d0fe2d 100644
--- a/.idea/runConfigurations/spackle_lib_connectedCheck.xml
+++ b/.idea/runConfigurations/crash_android_lib_connectedCheck.xml
@@ -1,6 +1,6 @@
-
-
+
+
@@ -13,8 +13,6 @@
-
-
diff --git a/.idea/runConfigurations/ktlintFormat.xml b/.idea/runConfigurations/ktlintFormat.xml
index dc632087..371903c2 100644
--- a/.idea/runConfigurations/ktlintFormat.xml
+++ b/.idea/runConfigurations/ktlintFormat.xml
@@ -4,12 +4,14 @@
-
+
-
+
+
+
diff --git a/.idea/runConfigurations/spackle_android_lib_connectedCheck.xml b/.idea/runConfigurations/spackle_android_lib_connectedCheck.xml
new file mode 100644
index 00000000..e94ca930
--- /dev/null
+++ b/.idea/runConfigurations/spackle_android_lib_connectedCheck.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/testDebugWithEmulatorWtf.xml b/.idea/runConfigurations/testDebugWithEmulatorWtf.xml
new file mode 100644
index 00000000..9603a184
--- /dev/null
+++ b/.idea/runConfigurations/testDebugWithEmulatorWtf.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f68fbc30..29ab04ef 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -138,6 +138,9 @@ dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.zcash.sdk) // just to configure logging
+ implementation(projects.crashAndroidLib)
+ implementation(projects.spackleAndroidLib)
implementation(projects.uiLib)
androidTestImplementation(projects.testLib)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index be612979..68809d92 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,10 +1,9 @@
-
@@ -13,9 +12,9 @@
clients. -->
+ android:targetActivity="co.electriccoin.zcash.ui.MainActivity">
diff --git a/app/src/main/java/co/electriccoin/zcash/app/AppImpl.kt b/app/src/main/java/co/electriccoin/zcash/app/AppImpl.kt
deleted file mode 100644
index 251b7488..00000000
--- a/app/src/main/java/co/electriccoin/zcash/app/AppImpl.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package co.electriccoin.zcash.app
-
-import android.app.Application
-import co.electriccoin.zcash.BuildConfig
-
-@Suppress("unused")
-class AppImpl : Application() {
-
- override fun onCreate() {
- super.onCreate()
-
- if (BuildConfig.DEBUG) {
- StrictModeHelper.enableStrictMode()
- }
- }
-}
diff --git a/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt b/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt
new file mode 100644
index 00000000..1b679bc4
--- /dev/null
+++ b/app/src/main/java/co/electriccoin/zcash/app/ZcashApplication.kt
@@ -0,0 +1,30 @@
+package co.electriccoin.zcash.app
+
+import android.app.Application
+import co.electriccoin.zcash.BuildConfig
+import co.electriccoin.zcash.crash.android.CrashReporter
+import co.electriccoin.zcash.spackle.StrictModeCompat
+import co.electriccoin.zcash.spackle.Twig
+
+@Suppress("unused")
+class ZcashApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ Twig.initialize(applicationContext)
+ Twig.info { "Starting application…" }
+
+ if (BuildConfig.DEBUG) {
+ StrictModeCompat.enableStrictMode()
+
+ // 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)
+ }
+}
diff --git a/crash-android-lib/build.gradle.kts b/crash-android-lib/build.gradle.kts
new file mode 100644
index 00000000..78123791
--- /dev/null
+++ b/crash-android-lib/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ id("zcash.android-build-conventions")
+ id("wtf.emulator.gradle")
+ id("zcash.emulator-wtf-conventions")
+}
+
+// Note that we force enable test orchestrator for this module, because some of the test cases require it.
+// Specifically this is needed due to checks on the UncaughtExceptionHandler tests
+
+android {
+ defaultConfig {
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
+ }
+
+ testOptions {
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring(libs.desugaring)
+
+ api(projects.crashLib)
+ api(libs.androidx.annotation)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(projects.spackleAndroidLib)
+
+ androidTestImplementation(libs.bundles.androidx.test)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+
+ androidTestUtil(libs.androidx.test.orchestrator) {
+ artifact {
+ type = "apk"
+ }
+ }
+}
diff --git a/crash-android-lib/gradle.lockfile b/crash-android-lib/gradle.lockfile
new file mode 100644
index 00000000..239dd0ac
--- /dev/null
+++ b/crash-android-lib/gradle.lockfile
@@ -0,0 +1,35 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+net.java.dev.jna:jna:5.6.0=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.6.20=kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-script-runtime:1.6.20=kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-scripting-common:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-jvm:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-stdlib-common:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-test-annotations-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-junit:1.6.20=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlin:kotlin-test:1.6.20=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:atomicfu:0.17.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.6.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.3.2=jvmCompileClasspath
+org.jetbrains.kotlinx:kotlinx-datetime:0.3.2=allSourceSetsCompileDependenciesMetadata,jvmCompileClasspath
+org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.1=allSourceSetsCompileDependenciesMetadata
+org.jetbrains:annotations:13.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+empty=archives,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonMainRuntimeOnlyDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,commonTestRuntimeOnlyDependenciesMetadata,default,jvmMainCompileOnlyDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmMainIntransitiveDependenciesMetadata,jvmMainRuntimeOnlyDependenciesMetadata,jvmTestCompileOnlyDependenciesMetadata,jvmTestIntransitiveDependenciesMetadata,jvmTestRuntimeOnlyDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDef,kotlinScriptDefExtensions,testKotlinScriptDef,testKotlinScriptDefExtensions
diff --git a/crash-android-lib/proguard-consumer.txt b/crash-android-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/crash-android-lib/src/androidTest/AndroidManifest.xml b/crash-android-lib/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000..9d7692df
--- /dev/null
+++ b/crash-android-lib/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
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/AndroidUncaughtExceptionHandlerTest.kt
new file mode 100644
index 00000000..9dfcbcf9
--- /dev/null
+++ b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandlerTest.kt
@@ -0,0 +1,44 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import android.os.Handler
+import android.os.Looper
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.fail
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicBoolean
+
+class AndroidUncaughtExceptionHandlerTest {
+
+ @Test(expected = IllegalStateException::class)
+ fun requires_main_thread() {
+ AndroidUncaughtExceptionHandler.register(ApplicationProvider.getApplicationContext())
+ }
+
+ @Test
+ fun cannot_initialize_twice() {
+ val didFail = AtomicBoolean(true)
+ val latch = CountDownLatch(1)
+ Handler(Looper.getMainLooper()).post {
+ runCatching { AndroidUncaughtExceptionHandler.register(ApplicationProvider.getApplicationContext()) }
+ .onFailure {
+ throw AssertionError("Failed to register once")
+ }
+
+ // Expected to fail on second registration
+ try {
+ AndroidUncaughtExceptionHandler.register(ApplicationProvider.getApplicationContext())
+ } catch (e: IllegalStateException) {
+ // Expected exception
+ didFail.set(false)
+ latch.countDown()
+ }
+ }
+
+ latch.await()
+
+ if (didFail.get()) {
+ fail("Second initialization did not fail")
+ }
+ }
+}
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/ReportableExceptionTest.kt
new file mode 100644
index 00000000..412b16e9
--- /dev/null
+++ b/crash-android-lib/src/androidTest/kotlin/co/electriccoin/zcash/crash/android/internal/ReportableExceptionTest.kt
@@ -0,0 +1,19 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import co.electriccoin.zcash.crash.ReportableException
+import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReportableExceptionTest {
+
+ @Test
+ fun bundle() {
+ val reportableException = ReportableExceptionFixture.new()
+
+ val bundle = reportableException.toBundle()
+ val fromBundle = ReportableException.fromBundle(bundle)
+
+ assertEquals(reportableException, fromBundle)
+ }
+}
diff --git a/crash-android-lib/src/main/AndroidManifest.xml b/crash-android-lib/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..d5fbd0b1
--- /dev/null
+++ b/crash-android-lib/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/AndroidExceptionPath.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/AndroidExceptionPath.kt
new file mode 100644
index 00000000..047018e5
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/AndroidExceptionPath.kt
@@ -0,0 +1,35 @@
+package co.electriccoin.zcash.crash.android
+
+import android.content.Context
+import co.electriccoin.zcash.crash.ExceptionPath
+import co.electriccoin.zcash.crash.ReportableException
+import co.electriccoin.zcash.spackle.Twig
+import co.electriccoin.zcash.spackle.getExternalFilesDirSuspend
+import java.io.File
+
+@Suppress("ReturnCount")
+suspend fun ExceptionPath.getExceptionDirectory(context: Context): File? {
+ val exceptionDirectory = context.getExternalFilesDirSuspend(null)
+ ?.let { File(File(it, ExceptionPath.LOG_DIRECTORY_NAME), ExceptionPath.EXCEPTION_DIRECTORY_NAME) }
+
+ if (null == exceptionDirectory) {
+ Twig.info { "Unable to get external storage directory; external storage may not be available" }
+ return null
+ }
+
+ try {
+ validateDir(exceptionDirectory)
+ } catch (e: IllegalArgumentException) {
+ Twig.info(e) { "Unable to get exception directory" }
+ return null
+ }
+
+ return exceptionDirectory
+}
+
+suspend fun ExceptionPath.getExceptionPath(context: Context, exception: ReportableException): File? {
+ val exceptionDirectory = getExceptionDirectory(context)
+ ?: return null
+
+ return File(exceptionDirectory, newExceptionFileName(exception))
+}
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
new file mode 100644
index 00000000..4ff03078
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/CrashReporter.kt
@@ -0,0 +1,53 @@
+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/internal/AndroidExceptionReporter.kt b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidExceptionReporter.kt
new file mode 100644
index 00000000..eb93260a
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidExceptionReporter.kt
@@ -0,0 +1,27 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import android.content.Context
+import android.media.MediaScannerConnection
+import co.electriccoin.zcash.crash.ExceptionPath
+import co.electriccoin.zcash.crash.ReportableException
+import co.electriccoin.zcash.crash.android.getExceptionPath
+import co.electriccoin.zcash.crash.write
+
+object AndroidExceptionReporter {
+ internal suspend fun reportException(context: Context, reportableException: ReportableException) {
+ val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException)
+ ?: return
+
+ reportableException.write(exceptionPath)
+
+ // Media Scan necessary for files to immediately show up as visible
+ // Note: must break out of BroadcastReceiver context in order to start media
+ // scanner service.
+ MediaScannerConnection.scanFile(
+ context.applicationContext,
+ arrayOf(exceptionPath.absolutePath),
+ null,
+ null
+ )
+ }
+}
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/AndroidReportableException.kt
new file mode 100644
index 00000000..ce9798ae
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidReportableException.kt
@@ -0,0 +1,60 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import android.content.Context
+import android.os.Bundle
+import co.electriccoin.zcash.crash.ReportableException
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+
+fun ReportableException.Companion.new(
+ context: Context,
+ throwable: Throwable,
+ isUncaught: Boolean,
+ clock: Clock = Clock.System
+): ReportableException {
+ val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
+ ?: "null"
+
+ return ReportableException(
+ throwable.javaClass.name,
+ throwable.stackTraceToString(),
+ versionName,
+ isUncaught,
+ clock.now()
+ )
+}
+
+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)
+ putSerializable(ReportableException.EXTRA_STRING_TRACE, exceptionTrace)
+ putString(ReportableException.EXTRA_STRING_APP_VERSION, appVersion)
+ putBoolean(ReportableException.EXTRA_BOOLEAN_IS_UNCAUGHT, isUncaught)
+ putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds())
+}
+
+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)!!
+ val isUncaught = bundle.getBoolean(EXTRA_BOOLEAN_IS_UNCAUGHT, false)
+ val time = Instant.fromEpochMilliseconds(bundle.getLong(EXTRA_LONG_WALLTIME_MILLIS, 0))
+
+ return ReportableException(className, trace, appVersion, isUncaught, time)
+}
+
+private val ReportableException.Companion.EXTRA_STRING_CLASS_NAME
+ get() = "co.electriccoin.zcash.crash.extra.STRING_CLASS_NAME" // $NON-NLS-1$
+
+private val ReportableException.Companion.EXTRA_STRING_TRACE
+ get() = "co.electriccoin.zcash.crash.extra.STRING_TRACE" // $NON-NLS-1$
+
+private val ReportableException.Companion.EXTRA_STRING_APP_VERSION: String
+ get() = "co.electriccoin.zcash.crash.extra.STRING_APP_VERSION" // $NON-NLS-1$
+
+private val ReportableException.Companion.EXTRA_BOOLEAN_IS_UNCAUGHT
+ get() = "co.electriccoin.zcash.crash.extra.BOOLEAN_IS_UNCAUGHT" // $NON-NLS-1$
+
+private val ReportableException.Companion.EXTRA_LONG_WALLTIME_MILLIS
+ get() = "co.electriccoin.zcash.crash.extra.LONG_WALLTIME_MILLIS" // $NON-NLS-1$
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/AndroidUncaughtExceptionHandler.kt
new file mode 100644
index 00000000..46e312ea
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/AndroidUncaughtExceptionHandler.kt
@@ -0,0 +1,60 @@
+package co.electriccoin.zcash.crash.android.internal
+
+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(
+ context: Context,
+ private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
+) : Thread.UncaughtExceptionHandler {
+
+ private val applicationContext = context.applicationContext
+
+ override fun uncaughtException(t: Thread, e: Throwable) {
+ val reportableException = ReportableException.new(applicationContext, e, true)
+
+ val isUseSecondaryProcess = applicationContext.resources
+ .getBoolean(R.bool.co_electriccoin_zcash_crash_is_use_secondary_process)
+
+ if (isUseSecondaryProcess) {
+ applicationContext.sendBroadcast(ExceptionReceiver.newIntent(applicationContext, reportableException))
+ } else {
+ runBlocking { AndroidExceptionReporter.reportException(applicationContext, reportableException) }
+ }
+
+ defaultUncaughtExceptionHandler.uncaughtException(t, e)
+ }
+
+ companion object {
+
+ private val isInitialized = AtomicBoolean(false)
+
+ /**
+ * Call to register writing uncaught exceptions to external storage.
+ */
+ @MainThread
+ internal fun register(context: Context) {
+ 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))
+ }
+ }
+ }
+
+ 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/CrashProcessNameContentProvider.kt
new file mode 100644
index 00000000..14baed6c
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/CrashProcessNameContentProvider.kt
@@ -0,0 +1,5 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider
+
+class CrashProcessNameContentProvider : 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/ExceptionReceiver.kt
new file mode 100644
index 00000000..d4bcd79d
--- /dev/null
+++ b/crash-android-lib/src/main/kotlin/co/electriccoin/zcash/crash/android/internal/ExceptionReceiver.kt
@@ -0,0 +1,36 @@
+package co.electriccoin.zcash.crash.android.internal
+
+import android.content.Context
+import android.content.Intent
+import co.electriccoin.zcash.crash.ReportableException
+import co.electriccoin.zcash.spackle.CoroutineBroadcastReceiver
+import kotlinx.coroutines.GlobalScope
+
+@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
+class ExceptionReceiver : CoroutineBroadcastReceiver(GlobalScope) {
+
+ override suspend fun onReceiveSuspend(context: Context, intent: Intent) {
+ val reportableException = intent.extras?.let { ReportableException.fromBundle(it) }
+ ?: return
+
+ AndroidExceptionReporter.reportException(context, reportableException)
+ }
+
+ companion object {
+
+ /**
+ * @return Explicit intent to broadcast to log the exception.
+ */
+ fun newIntent(
+ context: Context,
+ reportableException: ReportableException
+ ) = Intent(context, ExceptionReceiver::class.java).apply {
+ // Use Intent.FLAG_RECEIVER_FOREGROUND to reduce likelihood that Android throttles
+ // the Intents, since the foreground receiver queue is usually significantly less loaded
+ // than the default background receiver queue. One tradeoff is that FOREGROUND Intents
+ // have less time (5 seconds) to do their work.
+ flags = Intent.FLAG_RECEIVER_FOREGROUND
+ putExtras(reportableException.toBundle())
+ }
+ }
+}
diff --git a/crash-android-lib/src/main/res/values/bools.xml b/crash-android-lib/src/main/res/values/bools.xml
new file mode 100644
index 00000000..648e3cd9
--- /dev/null
+++ b/crash-android-lib/src/main/res/values/bools.xml
@@ -0,0 +1,4 @@
+
+
+ true
+
\ No newline at end of file
diff --git a/crash-android-lib/src/main/res/values/strings.xml b/crash-android-lib/src/main/res/values/strings.xml
new file mode 100644
index 00000000..446a83fe
--- /dev/null
+++ b/crash-android-lib/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ :crash
+
diff --git a/crash-lib/build.gradle.kts b/crash-lib/build.gradle.kts
new file mode 100644
index 00000000..4ef197f0
--- /dev/null
+++ b/crash-lib/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ kotlin("multiplatform")
+ id("zcash.kotlin-multiplatform-build-conventions")
+ id("zcash.kotlin-multiplatform-jacoco-conventions")
+ id("zcash.dependency-conventions")
+ id("zcash.android-build-conventions")
+}
+
+kotlin {
+ jvm()
+ sourceSets {
+ getByName("commonMain") {
+ dependencies {
+ api(libs.kotlinx.coroutines.core)
+ api(libs.kotlinx.datetime)
+ implementation(projects.spackleLib)
+ }
+ }
+ getByName("commonTest") {
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
+ getByName("jvmMain") {
+ dependencies {
+ implementation(projects.spackleLib)
+ }
+ }
+ getByName("jvmTest") {
+ dependencies {
+ implementation(kotlin("test"))
+ }
+ }
+ }
+}
diff --git a/crash-lib/gradle.lockfile b/crash-lib/gradle.lockfile
new file mode 100644
index 00000000..313aa81d
--- /dev/null
+++ b/crash-lib/gradle.lockfile
@@ -0,0 +1,35 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+net.java.dev.jna:jna:5.6.0=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.6.20=kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-script-runtime:1.6.20=kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-scripting-common:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-jvm:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-stdlib-common:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-test-annotations-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-junit:1.6.20=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlin:kotlin-test:1.6.20=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:atomicfu:0.17.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.6.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.3.2=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-datetime:0.3.2=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.1=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata
+org.jetbrains:annotations:13.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+empty=archives,commonMainCompileOnlyDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonMainRuntimeOnlyDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,commonTestRuntimeOnlyDependenciesMetadata,default,jvmMainCompileOnlyDependenciesMetadata,jvmMainIntransitiveDependenciesMetadata,jvmMainRuntimeOnlyDependenciesMetadata,jvmTestCompileOnlyDependenciesMetadata,jvmTestIntransitiveDependenciesMetadata,jvmTestRuntimeOnlyDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDef,kotlinScriptDefExtensions,testKotlinScriptDef,testKotlinScriptDefExtensions
diff --git a/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportableException.kt b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportableException.kt
new file mode 100644
index 00000000..c359e2cb
--- /dev/null
+++ b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportableException.kt
@@ -0,0 +1,14 @@
+package co.electriccoin.zcash.crash
+
+import kotlinx.datetime.Instant
+
+data class ReportableException(
+ val exceptionClass: String,
+ val exceptionTrace: String,
+ val appVersion: String,
+ val isUncaught: Boolean,
+ val time: Instant
+) {
+
+ companion object
+}
diff --git a/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportedException.kt b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportedException.kt
new file mode 100644
index 00000000..c5b1bad0
--- /dev/null
+++ b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/ReportedException.kt
@@ -0,0 +1,12 @@
+package co.electriccoin.zcash.crash
+
+import kotlinx.datetime.Instant
+
+data class ReportedException(
+ val filePath: String,
+ val exceptionClassName: String,
+ val isUncaught: Boolean,
+ val time: Instant
+) {
+ companion object
+}
diff --git a/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/fixture/ReportableExceptionFixture.kt b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/fixture/ReportableExceptionFixture.kt
new file mode 100644
index 00000000..666fe56c
--- /dev/null
+++ b/crash-lib/src/commonMain/kotlin/co/electriccoin/zcash/crash/fixture/ReportableExceptionFixture.kt
@@ -0,0 +1,24 @@
+package co.electriccoin.zcash.crash.fixture
+
+import co.electriccoin.zcash.crash.ReportableException
+import kotlinx.datetime.Instant
+import kotlinx.datetime.toInstant
+
+object ReportableExceptionFixture {
+ private val EXCEPTION = RuntimeException("I am exceptional")
+ val CLASS = EXCEPTION.javaClass.name
+ val TRACE = EXCEPTION.stackTraceToString()
+ const val APP_VERSION = "1.0.2"
+ const val IS_UNCAUGHT = true
+
+ // No milliseconds, because those can cause some tests to fail due to rounding
+ val TIMESTAMP = "2022-04-15T11:28:54Z".toInstant()
+
+ fun new(
+ className: String = CLASS,
+ trace: String = TRACE,
+ appVersion: String = APP_VERSION,
+ isUncaught: Boolean = IS_UNCAUGHT,
+ time: Instant = TIMESTAMP
+ ) = ReportableException(className, trace, appVersion, isUncaught, time)
+}
diff --git a/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ExceptionPath.kt b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ExceptionPath.kt
new file mode 100644
index 00000000..9af2cec0
--- /dev/null
+++ b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ExceptionPath.kt
@@ -0,0 +1,37 @@
+package co.electriccoin.zcash.crash
+
+import co.electriccoin.zcash.spackle.io.canWriteSuspend
+import co.electriccoin.zcash.spackle.io.existsSuspend
+import co.electriccoin.zcash.spackle.io.isDirectorySuspend
+import co.electriccoin.zcash.spackle.io.mkdirsSuspend
+import java.io.File
+import java.util.UUID
+
+object ExceptionPath {
+ const val LOG_DIRECTORY_NAME = "log" // $NON-NLS-1$
+ const val EXCEPTION_DIRECTORY_NAME = "exception" // $NON-NLS-1$
+ const val SEPARATOR = "|"
+ const val TYPE = "txt"
+
+ @Suppress("MaxLineLength")
+ fun newExceptionFileName(exception: ReportableException, uuid: UUID = UUID.randomUUID()) =
+ "${exception.time.epochSeconds}$SEPARATOR$uuid$SEPARATOR${exception.exceptionClass}$SEPARATOR${exception.isUncaught}.$TYPE"
+
+ // The exceptions are really just for debugging
+ @Suppress("ThrowsCount")
+ suspend fun validateDir(path: File) {
+ if (!path.existsSuspend()) {
+ if (!path.mkdirsSuspend()) {
+ throw IllegalArgumentException("Directories couldn't be created")
+ }
+ } else {
+ if (!path.isDirectorySuspend()) {
+ throw IllegalArgumentException("Path is a file when a directory was expected")
+ }
+ }
+
+ if (!path.canWriteSuspend()) {
+ throw IllegalArgumentException("Path is not writeable")
+ }
+ }
+}
diff --git a/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportableExceptionExt.kt b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportableExceptionExt.kt
new file mode 100644
index 00000000..4a50ab0b
--- /dev/null
+++ b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportableExceptionExt.kt
@@ -0,0 +1,21 @@
+package co.electriccoin.zcash.crash
+
+import co.electriccoin.zcash.spackle.io.writeAtomically
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+
+suspend fun ReportableException.write(path: File) {
+ val exceptionString = buildString {
+ appendLine("App version: $appVersion")
+ appendLine("Is uncaught: $isUncaught")
+ appendLine("Time: $time")
+ append(exceptionTrace)
+ }
+
+ withContext(Dispatchers.IO) {
+ path.writeAtomically { tempFile ->
+ tempFile.writeText(exceptionString)
+ }
+ }
+}
diff --git a/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExt.kt b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExt.kt
new file mode 100644
index 00000000..fa90548d
--- /dev/null
+++ b/crash-lib/src/jvmMain/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExt.kt
@@ -0,0 +1,27 @@
+package co.electriccoin.zcash.crash
+
+import kotlinx.datetime.Instant
+import java.io.File
+
+fun ReportedException.Companion.new(file: File): ReportedException? {
+ // Exclude temp files
+ if (file.extension == ExceptionPath.TYPE) {
+ val name: String = file.nameWithoutExtension
+ val splitName = name.split(ExceptionPath.SEPARATOR)
+
+ val epochSeconds = splitName.firstOrNull()?.toLongOrNull()
+ val classNameString = splitName.getOrNull(2)
+ val isUncaught = splitName.lastOrNull()?.toBoolean()
+
+ if (null != epochSeconds && null != classNameString && null != isUncaught) {
+ return ReportedException(
+ filePath = file.path,
+ exceptionClassName = classNameString,
+ isUncaught = isUncaught,
+ time = Instant.fromEpochSeconds(epochSeconds)
+ )
+ }
+ }
+
+ return null
+}
diff --git a/crash-lib/src/jvmTest/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExtTest.kt b/crash-lib/src/jvmTest/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExtTest.kt
new file mode 100644
index 00000000..227ad0ac
--- /dev/null
+++ b/crash-lib/src/jvmTest/kotlin/co/electriccoin/zcash/crash/ReportedExceptionExtTest.kt
@@ -0,0 +1,29 @@
+package co.electriccoin.zcash.crash
+
+import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+class ReportedExceptionExtTest {
+ @Test
+ fun only_txt_files() {
+ assertEquals(ReportedException.new(File("something.txt.tmp")), null)
+ }
+
+ @Test
+ fun able_to_parse() {
+ val filename = ExceptionPath.newExceptionFileName(ReportableExceptionFixture.new())
+
+ val parsed = ReportedException.new(File(filename))
+
+ assertNotNull(parsed)
+
+ assertEquals(ReportableExceptionFixture.CLASS, parsed.exceptionClassName)
+ // Note the timestamp is rounded to the nearest second
+ assertEquals(ReportableExceptionFixture.TIMESTAMP, parsed.time)
+ assertEquals(filename, parsed.filePath)
+ assertEquals(ReportableExceptionFixture.IS_UNCAUGHT, parsed.isUncaught)
+ }
+}
diff --git a/docs/Architecture.md b/docs/Architecture.md
index e6b67d77..42251e9d 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -10,7 +10,7 @@ _Note: This document will continue to be updated as the app is implemented._
# Multiplatform
While this repository is for an Android application, efforts are made to give multiplatform flexibility in the future. Specific adaptions that are being made:
- * Where possible, common code is extracted into multiplatform modules
+ * Where possible, common code is extracted into multiplatform modules. This sometimes means that additional modules with an `-android` suffix exist to add Android-specific extensions. In the future, we would like to move towards multiplatform modules with source directories along the lines of commonJvmMain, jvmMain, and androidMain.
* In UI state management code, Kotlin Flow is often preferred over Android LiveData and Compose State to grant future flexibility
* Saver is preferred over @Parcelize for objects in the SDK
@@ -26,6 +26,9 @@ The logical components of the app are implemented as a number of Gradle modules.
* `app` — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `co.electriccoin.zcash.app` while the Android package name is `co.electriccoin.zcash`.
* `build-info-lib` — Collects information from the build environment (e.g. Git SHA, Git commit count) and compiles them into the application. Can also be used for injection of API keys or other secrets.
+ * `crash` — For collecting and reporting exceptions and crashes
+ * `crash-lib` — Common crash collection logic for Kotlin and JVM. This is not fully-featured by itself, but the long-term plan is multiplatform support.
+ * `crash-android-lib` — Android-specific crash collection logic, built on top of the common and JVM implementation in `crash-lib`
* ui
* `ui-design` — Contains UI theme elements only. Besides offering modularization, this allows for hiding of some Material Design components behind our own custom components.
* `ui-lib` — User interface that the user interacts with. This contains 99% of the UI code, along with localizations, icons, and other assets.
@@ -34,7 +37,9 @@ The logical components of the app are implemented as a number of Gradle modules.
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
* `sdk-ext-ui` — Place for Zcash SDK components (same as `sdk-ext-lib`), which are related to the UI (e.g. depend on user locale and thus need to be translated via `strings.xml`).
- * `spackle-lib` — Random utilities, to fill in the cracks in the Kotlin and Android frameworks.
+ * `spackle` — Random utilities, to fill in the cracks in the frameworks.
+ * `spackle-lib` — Multiplatform implementation for Kotlin and JVM
+ * `spackle-android-lib` — Android-specific additions.
The following diagram shows a rough depiction of dependencies between the modules. Two notes on this diagram:
* `sdk-lib` is in a [different repository](https://github.com/zcash/zcash-android-wallet-sdk)
@@ -45,20 +50,31 @@ The following diagram shows a rough depiction of dependencies between the module
subgraph sdk
sdkLib[[sdk-lib]];
sdkExtLib[[sdk-ext-lib]];
- sdkExtUI[[sdk-ext-ui]];
+ sdkExtUi[[sdk-ext-ui]];
end
- sdkLib[[sdk-lib]] --> sdkExtLib[[sdk-ext-lib]] --> sdkExtUI[[sdk-ext-ui]];
+ sdkLib[[sdk-lib]] --> sdkExtLib[[sdk-ext-lib]] --> sdkExtUi[[sdk-ext-ui]];
subgraph preference
- preference-api-lib[[preference-api-lib]];
- preference-impl-android-lib[[preference-impl-android-lib]];
+ preferenceApiLib[[preference-api-lib]];
+ preferenceImplAndroidLib[[preference-impl-android-lib]];
end
- preference-api-lib[[preference-api-lib]] --> preference-impl-android-lib[[preference-impl-android-lib]];
- preference --> ui-lib[[ui-lib]];
- sdk --> ui-lib[[ui-lib]];
- spackle-lib[[spackle-lib]] --> ui-design-lib[[ui-design-lib]];
- spackle-lib[[spackle-lib]] --> ui-lib[[ui-lib]];
- ui-design-lib[[ui-design-lib]] --> ui-lib[[ui-lib]];
- ui-lib[[ui-lib]] --> app{app};
+ preferenceApiLib[[preference-api-lib]] --> preferenceImplAndroidLib[[preference-impl-android-lib]];
+ subgraph crash
+ crashLib[[crash-lib]];
+ crashAndroidLib[[crash-android-lib]];
+ end
+ crashLib[[crash-lib]] --> crashAndroidLib[[crash-android-lib]];
+ subgraph spackle
+ spackleLib[[spackle-lib]];
+ spackleAndroidLib[[spackle-android-lib]];
+ end
+ spackleLib[[spackle-lib]] --> spackleAndroidLib[[spackle-android-lib]];
+ preference --> uiLib[[ui-lib]];
+ sdk --> uiLib[[ui-lib]];
+ spackle[[spackle]] --> uiDesignLib[[ui-design-lib]];
+ spackle[[spackle]] --> uiLib[[ui-lib]];
+ uiDesignLib[[ui-design-lib]] --> uiLib[[ui-lib]];
+ crash[[crash]] --> app{app};
+ uiLib[[ui-lib]] --> app{app};
```
# Test Fixtures
diff --git a/docs/testing/manual_testing/Contact Support.md b/docs/testing/manual_testing/Contact Support.md
index edc72b92..65ec6875 100644
--- a/docs/testing/manual_testing/Contact Support.md
+++ b/docs/testing/manual_testing/Contact Support.md
@@ -2,6 +2,7 @@ Note: Contact Support will fail on some devices without an app to handle email,
# Check Support Email Contents
1. If using a test device or emulator, be sure to configure a default email app. For example, try opening the Gmail app and confirm that it shows your inbox.
+1. Clear the app's data. This ensures no crash reports exist on external storage
1. Open the Zcash app
1. Navigate to Profile
1. Navigate to Support
@@ -10,7 +11,7 @@ Note: Contact Support will fail on some devices without an app to handle email,
1. Choose OK
1. Verify that the email app opens with a pre-filled message. The email subject should be "Zcash", the recipient should be the correct support email address, and the message body should include the message typed above, along with information about the user's current setup.
-# Verify support
+# Verify Support Screen Closes After Send
1. If using a test device or emulator, be sure to configure a default email app. For example, try opening the Gmail app and confirm that it shows your inbox.
1. Open the Zcash app
1. Navigate to Profile
@@ -19,4 +20,17 @@ Note: Contact Support will fail on some devices without an app to handle email,
1. Choose send
1. Choose OK
1. After the email app opens, task switch back to the Zcash app
-1. Verify that you're returned to the Profile screen (specifically confirm the Support screen with the confirmation dialog is no longer on the screen)
\ No newline at end of file
+1. Verify that you're returned to the Profile screen (specifically confirm the Support screen with the confirmation dialog is no longer on the screen)
+
+# With Crashes
+1. If using a test device or emulator, be sure to configure a default email app. For example, try opening the Gmail app and confirm that it shows your inbox.
+1. Install a debug build of the app
+1. From the Home screen, choose Throw Uncaught Exception from the debug menu
+1. Repeat that at least 6 times
+1. Open the Zcash app
+1. Navigate to Profile
+1. Navigate to Support
+1. Type a message
+1. Choose send
+1. Choose OK
+1. Verify that the email app opens with a pre-filled message. Specifically look at the Exceptions section, verifying that it contains 5 entries (we limit to 5 to keep the email from getting too long). Note that the number of reported exceptions is set via `CrashInfo.MAX_EXCEPTIONS_TO_REPORT` and could be changed over time.
\ No newline at end of file
diff --git a/docs/testing/manual_testing/Crash Reporting.md b/docs/testing/manual_testing/Crash Reporting.md
new file mode 100644
index 00000000..93008f88
--- /dev/null
+++ b/docs/testing/manual_testing/Crash Reporting.md
@@ -0,0 +1,13 @@
+The application will log crashes to external storage and can also include some information about these when a user contacts us for support.
+
+# Crashes Reported to External Storage
+1. Uninstall the app, to clear external storage
+2. Install a debug build of the app
+3. Get past the onboarding to reach the Home screen
+4. Under the debug menu, choose Report Caught Exception
+5. Look under the app's external storage directory `/sdcard/Android/data/co.electroiccoin.zcash/files/log/exception/`
+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 in Contact Support
+1. See the Contact Support test cases
\ No newline at end of file
diff --git a/docs/testing/manual_testing/Logging.md b/docs/testing/manual_testing/Logging.md
new file mode 100644
index 00000000..619e680c
--- /dev/null
+++ b/docs/testing/manual_testing/Logging.md
@@ -0,0 +1,8 @@
+# Logging disabled on release builds
+1. Create a release build of the app, e.g. `./gradlew assembleRelease`
+1. Install the build, e.g. `adb install [path_to_apk]`
+1. Start logcat, e.g. `adb logcat`
+1. Launch the app
+1. Verify that the app does not crash on launch (the app is designed to crash if logs are enabled in release builds)
+1. Verify that no logs from the app appear in logcat
+
diff --git a/preference-impl-android-lib/proguard-consumer.txt b/preference-impl-android-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/sdk-ext-lib/proguard-consumer.txt b/sdk-ext-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/sdk-ext-ui-lib/proguard-consumer.txt b/sdk-ext-ui-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9626874a..71a19cd5 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -257,11 +257,14 @@ includeBuild("build-convention")
include("app")
include("build-info-lib")
+include("crash-lib")
+include("crash-android-lib")
include("preference-api-lib")
include("preference-impl-android-lib")
include("sdk-ext-lib")
include("sdk-ext-ui-lib")
include("spackle-lib")
+include("spackle-android-lib")
include("test-lib")
include("ui-design-lib")
include("ui-lib")
diff --git a/spackle-android-lib/build.gradle.kts b/spackle-android-lib/build.gradle.kts
new file mode 100644
index 00000000..15393e23
--- /dev/null
+++ b/spackle-android-lib/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ id("kotlin-parcelize")
+ id("zcash.android-build-conventions")
+ id("wtf.emulator.gradle")
+ id("zcash.emulator-wtf-conventions")
+}
+
+android {
+ // Force orchestrator to be used for this module, because we need the process name to be purged between tests
+ defaultConfig {
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
+ }
+
+ testOptions {
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
+}
+
+dependencies {
+ api(projects.spackleLib)
+ implementation(libs.androidx.annotation)
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.coroutines.core)
+
+ androidTestImplementation(libs.bundles.androidx.test)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+
+ androidTestUtil(libs.androidx.test.orchestrator) {
+ artifact {
+ type = "apk"
+ }
+ }
+}
diff --git a/spackle-android-lib/proguard-consumer.txt b/spackle-android-lib/proguard-consumer.txt
new file mode 100644
index 00000000..665632c1
--- /dev/null
+++ b/spackle-android-lib/proguard-consumer.txt
@@ -0,0 +1,9 @@
+# Strip out log messages
+-assumenosideeffects public class co.electriccoin.zcash.spackle.Twig {
+ public static *** verbose(...);
+ public static *** debug(...);
+ public static *** info(...);
+ public static *** warn(...);
+ public static *** error(...);
+ public static *** assertLoggingStripped();
+}
diff --git a/spackle-lib/src/androidTest/AndroidManifest.xml b/spackle-android-lib/src/androidTest/AndroidManifest.xml
similarity index 100%
rename from spackle-lib/src/androidTest/AndroidManifest.xml
rename to spackle-android-lib/src/androidTest/AndroidManifest.xml
diff --git a/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProviderTest.kt b/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProviderTest.kt
new file mode 100644
index 00000000..f4dcb375
--- /dev/null
+++ b/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProviderTest.kt
@@ -0,0 +1,28 @@
+package co.electriccoin.zcash.spackle.process
+
+import android.content.ContextWrapper
+import android.content.pm.ApplicationInfo
+import android.content.pm.ProviderInfo
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SmallTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AbstractProcessNameContentProviderTest {
+ @Test
+ @SmallTest
+ fun getProcessName_from_provider_info() {
+ val expectedApplicationProcessName = "beep" // $NON-NLS
+ val ctx: ContextWrapper = object : ContextWrapper(ApplicationProvider.getApplicationContext()) {
+ override fun getApplicationInfo() = ApplicationInfo().apply {
+ processName = expectedApplicationProcessName
+ }
+ }
+
+ val actualProcessName = AbstractProcessNameContentProvider.getProcessNameLegacy(
+ ctx, ProviderInfo()
+ )
+
+ assertEquals(expectedApplicationProcessName, actualProcessName)
+ }
+}
diff --git a/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/ProcessNameCompatTest.kt b/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/ProcessNameCompatTest.kt
new file mode 100644
index 00000000..81e05fa6
--- /dev/null
+++ b/spackle-android-lib/src/androidTest/java/co/electriccoin/zcash/spackle/process/ProcessNameCompatTest.kt
@@ -0,0 +1,24 @@
+package co.electriccoin.zcash.spackle.process
+
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SmallTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProcessNameCompatTest {
+ @SmallTest
+ @Test
+ fun searchForProcessName() {
+ assertEquals(TEST_PACKAGE_PROCESS, ProcessNameCompat.searchForProcessNameLegacy(ApplicationProvider.getApplicationContext()))
+ }
+
+ @SmallTest
+ @Test
+ fun getProcessName() {
+ assertEquals(TEST_PACKAGE_PROCESS, ProcessNameCompat.getProcessName(ApplicationProvider.getApplicationContext()))
+ }
+
+ companion object {
+ const val TEST_PACKAGE_PROCESS = "co.electriccoin.zcash.spackle.test"
+ }
+}
diff --git a/spackle-android-lib/src/main/AndroidManifest.xml b/spackle-android-lib/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..dc5c07fe
--- /dev/null
+++ b/spackle-android-lib/src/main/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/AndroidApiVersion.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/AndroidApiVersion.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/AndroidApiVersion.kt
rename to spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/AndroidApiVersion.kt
diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/ContextExt.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/ContextExt.kt
new file mode 100644
index 00000000..c3d3f32f
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/ContextExt.kt
@@ -0,0 +1,9 @@
+package co.electriccoin.zcash.spackle
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+suspend fun Context.getExternalFilesDirSuspend(type: String?) = withContext(Dispatchers.IO) {
+ getExternalFilesDir(type)
+}
diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/CoroutineBroadcastReceiver.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/CoroutineBroadcastReceiver.kt
new file mode 100644
index 00000000..ae9007d6
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/CoroutineBroadcastReceiver.kt
@@ -0,0 +1,33 @@
+package co.electriccoin.zcash.spackle
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * @param broadcastReceiverScope Scope for performing asynchronous work in the broadcast receiver.
+ * It is not recommended to cancel this scope.
+ */
+abstract class CoroutineBroadcastReceiver(private val broadcastReceiverScope: CoroutineScope) : BroadcastReceiver() {
+ final override fun onReceive(context: Context, intent: Intent) {
+ val pendingResult = goAsync()
+
+ broadcastReceiverScope.launch {
+ onReceiveSuspend(context, intent)
+
+ // Race condition here: if the broadcastReceiverScope is canceled before this
+ // completes, then the BroadcastReceiver will trigger an Application Not Responding
+ // because the PendingResult was leaked.
+ pendingResult.finish()
+ }
+ }
+
+ /**
+ * Override to perform work asynchronously. Note that this method must be quick to avoid
+ * the Android timeout for broadcast receivers. This method is suitable for brief disk IO but
+ * not suitable for network calls.
+ */
+ abstract suspend fun onReceiveSuspend(context: Context, intent: Intent)
+}
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt
similarity index 94%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt
rename to spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt
index 0fad240c..13e50860 100644
--- a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/EmulatorWtfUtil.kt
@@ -28,7 +28,7 @@ object EmulatorWtfUtil {
SETTING_TRUE == Settings.System.getString(context.contentResolver, EMULATOR_WTF_SETTING)
}.recover {
// Fail-safe in case an error occurs
- // 99.9% of the time, it won't be Firebase Test Lab
+ // 99.9% of the time, it won't be Emulator.wtf
false
}.getOrThrow()
}
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/FirebaseTestLabUtil.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/FirebaseTestLabUtil.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/FirebaseTestLabUtil.kt
rename to spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/FirebaseTestLabUtil.kt
diff --git a/app/src/main/java/co/electriccoin/zcash/app/StrictModeHelper.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/StrictModeCompat.kt
similarity index 90%
rename from app/src/main/java/co/electriccoin/zcash/app/StrictModeHelper.kt
rename to spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/StrictModeCompat.kt
index 2b2cfaac..a817befc 100644
--- a/app/src/main/java/co/electriccoin/zcash/app/StrictModeHelper.kt
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/StrictModeCompat.kt
@@ -1,4 +1,4 @@
-package co.electriccoin.zcash.app
+package co.electriccoin.zcash.spackle
import android.annotation.SuppressLint
import android.os.Build
@@ -6,7 +6,7 @@ import android.os.Handler
import android.os.Looper
import android.os.StrictMode
-object StrictModeHelper {
+object StrictModeCompat {
fun enableStrictMode() {
configureStrictMode()
@@ -32,7 +32,7 @@ object StrictModeHelper {
)
// Don't enable missing network tags, because those are noisy.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (AndroidApiVersion.isAtLeastO) {
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder().apply {
detectActivityLeaks()
@@ -42,7 +42,7 @@ object StrictModeHelper {
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
detectLeakedSqlLiteObjects()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ if (AndroidApiVersion.isAtLeastP) {
// Disable because this is mostly flagging Android X and Play Services
// builder.detectNonSdkApiUsage();
}
diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/Twig.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/Twig.kt
new file mode 100644
index 00000000..5b3aed04
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/Twig.kt
@@ -0,0 +1,147 @@
+package co.electriccoin.zcash.spackle
+
+import android.content.Context
+import android.util.Log
+import co.electriccoin.zcash.spackle.process.ProcessNameCompat
+import java.util.Locale
+
+/**
+ * A twig is a tiny log. These logs are intended for development rather than for high performance
+ * or usage in production.
+ */
+@Suppress("TooManyFunctions")
+object Twig {
+ /**
+ * Format string for log messages.
+ *
+ * The format is: .():
+ */
+ private const val FORMAT = "%-27s %-30s %s.%s(): %s" // $NON-NLS-1$
+
+ @Volatile
+ private var tag: String = "Twig"
+
+ @Volatile
+ private var processName: String = ""
+
+ /**
+ * For best results, call this method before trying to log messages.
+ */
+ fun initialize(context: Context) {
+ tag = getApplicationName(context)
+ processName = ProcessNameCompat.getProcessName(context)
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun verbose(message: () -> String) {
+ Log.v(tag, formatMessage(message))
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun verbose(throwable: Throwable, message: () -> String) {
+ Log.v(tag, formatMessage(message), throwable)
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun debug(message: () -> String) {
+ Log.d(tag, formatMessage(message))
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun debug(throwable: Throwable, message: () -> String) {
+ Log.d(tag, formatMessage(message), throwable)
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun info(message: () -> String) {
+ Log.i(tag, formatMessage(message))
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun info(throwable: Throwable, message: () -> String) {
+ Log.i(tag, formatMessage(message), throwable)
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun warn(message: () -> String) {
+ Log.w(tag, formatMessage(message))
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun warn(throwable: Throwable, message: () -> String) {
+ Log.w(tag, formatMessage(message), throwable)
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun error(message: () -> String) {
+ Log.e(tag, formatMessage(message))
+ }
+
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun error(throwable: Throwable, message: () -> String) {
+ Log.e(tag, formatMessage(message), throwable)
+ }
+
+ /**
+ * Can be called in a release build to test that `assumenosideeffects` ProGuard rules have been
+ * properly processed to strip out logging messages.
+ */
+ // JVMStatic is to simplify ProGuard/R8 rules for stripping this
+ @JvmStatic
+ fun assertLoggingStripped() {
+ @Suppress("MaxLineLength")
+ throw AssertionError("Logging was not disabled by ProGuard or R8. Logging should be disabled in release builds to reduce risk of sensitive information being leaked.") // $NON-NLS-1$
+ }
+
+ private const val CALL_DEPTH = 4
+
+ private fun formatMessage(message: () -> String): String {
+ val currentThread = Thread.currentThread()
+ val trace = currentThread.stackTrace
+ val sourceClass = trace[CALL_DEPTH].className
+ val sourceMethod = trace[CALL_DEPTH].methodName
+
+ return String.format(
+ Locale.ROOT,
+ FORMAT,
+ processName,
+ currentThread.name,
+ cleanupClassName(sourceClass),
+ sourceMethod,
+ message()
+ )
+ }
+}
+
+/**
+ * Gets the name of the application or the package name if the application has no name.
+ *
+ * @param context Application context.
+ * @return Label of the application from the Android Manifest or the package name if no label
+ * was set.
+ */
+fun getApplicationName(context: Context): String {
+ val applicationLabel = context.packageManager.getApplicationLabel(context.applicationInfo)
+
+ return applicationLabel.toString().lowercase(Locale.ROOT).replace(" ", "-")
+}
+
+private fun cleanupClassName(classNameString: String): String {
+ val outerClassName = classNameString.substringBefore('$')
+ val simplerOuterClassName = outerClassName.substringAfterLast('.')
+ return if (simplerOuterClassName.isEmpty()) {
+ classNameString
+ } else {
+ simplerOuterClassName.removeSuffix("Kt")
+ }
+}
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
new file mode 100644
index 00000000..b2c397ae
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/AbstractProcessNameContentProvider.kt
@@ -0,0 +1,72 @@
+package co.electriccoin.zcash.spackle.process
+
+import android.app.Application
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.ProviderInfo
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import androidx.annotation.RequiresApi
+import co.electriccoin.zcash.spackle.AndroidApiVersion
+
+/**
+ * Implement an empty subclass of this ContentProvider for each process the application uses.
+ *
+ * This works in conjunction with [ProcessNameCompat].
+ */
+open class AbstractProcessNameContentProvider : ContentProvider() {
+ override fun onCreate() = true
+
+ override fun attachInfo(context: Context, info: ProviderInfo) {
+ super.attachInfo(context, info)
+
+ val processName: String = if (AndroidApiVersion.isAtLeastP) {
+ getProcessNamePPlus()
+ } else {
+ getProcessNameLegacy(context, info)
+ }
+
+ ProcessNameCompat.setProcessName(processName)
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.P)
+ private fun getProcessNamePPlus(): String = Application.getProcessName()
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ throw UnsupportedOperationException()
+ }
+
+ override fun getType(uri: Uri): String? {
+ throw UnsupportedOperationException()
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ throw UnsupportedOperationException()
+ }
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ throw UnsupportedOperationException()
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ throw UnsupportedOperationException()
+ }
+
+ companion object {
+ internal fun getProcessNameLegacy(context: Context, info: ProviderInfo) =
+ info.processName ?: context.applicationInfo.processName ?: context.packageName
+ }
+}
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
new file mode 100644
index 00000000..52a23c28
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/ProcessNameCompat.kt
@@ -0,0 +1,80 @@
+package co.electriccoin.zcash.spackle.process
+
+import android.app.ActivityManager
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import android.os.Process
+import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
+import co.electriccoin.zcash.spackle.AndroidApiVersion
+import co.electriccoin.zcash.spackle.process.ProcessNameCompat.getProcessName
+
+/**
+ * Provides a reliable way of determining Android process name. For highest reliability and performance,
+ * [getProcessName] should only be called once the start of the Application.onCreate() callback has occurred
+ * which will mean that the process name content provider has been initialized.
+ *
+ * Note that if you wish to add another process, consider adding an empty subclass of
+ * [AbstractProcessNameContentProvider] in that process, as the ContentProvider has a more reliable
+ * way to get process name on older Android versions.
+ */
+object ProcessNameCompat {
+
+ // GuardedBy intrinsicLock
+ private var processName: String? = null
+
+ private val intrinsicLock = Any()
+
+ fun getProcessName(context: Context): String {
+ synchronized(intrinsicLock) {
+ processName?.let {
+ return it
+ }
+
+ val foundProcessName = searchForProcessName(context)
+ if (null == foundProcessName) {
+ // This should be exceedingly rare
+ throw IllegalStateException("Unable to determine process name")
+ } else {
+ processName = foundProcessName
+ return foundProcessName
+ }
+ }
+ }
+
+ /**
+ * Not a public API; should only be called by [AbstractProcessNameContentProvider].
+ */
+ internal fun setProcessName(newProcessName: String) {
+ processName = newProcessName
+ }
+
+ /**
+ * @param context Application context.
+ * @return Name of the current process. May return null if a failure occurs, which is possible
+ * due to some race conditions in Android.
+ */
+ private fun searchForProcessName(context: Context): String? {
+ return if (AndroidApiVersion.isAtLeastP) {
+ getProcessNamePPlus()
+ } else {
+ searchForProcessNameLegacy(context)
+ }
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.P)
+ private fun getProcessNamePPlus() = Application.getProcessName()
+
+ /**
+ * @param context Application context.
+ * @return Name of the current process. May return null if a failure occurs, which is possible
+ * due to some race conditions in older versions of Android.
+ */
+ @VisibleForTesting
+ internal fun searchForProcessNameLegacy(context: Context): String? {
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+
+ return activityManager.runningAppProcesses?.find { Process.myPid() == it.pid }?.processName
+ }
+}
diff --git a/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/internal/DefaultProcessContentProvider.kt b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/internal/DefaultProcessContentProvider.kt
new file mode 100644
index 00000000..77e7a596
--- /dev/null
+++ b/spackle-android-lib/src/main/kotlin/co/electriccoin/zcash/spackle/process/internal/DefaultProcessContentProvider.kt
@@ -0,0 +1,9 @@
+package co.electriccoin.zcash.spackle.process.internal
+
+import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider
+
+/**
+ * This is internal-only and not a public API. The class itself must be public to support
+ * adding the entry to the AndroidManifest.
+ */
+class DefaultProcessContentProvider : AbstractProcessNameContentProvider()
diff --git a/spackle-lib/build.gradle.kts b/spackle-lib/build.gradle.kts
index 1886df69..21ee1d3e 100644
--- a/spackle-lib/build.gradle.kts
+++ b/spackle-lib/build.gradle.kts
@@ -1,36 +1,32 @@
plugins {
- id("com.android.library")
- kotlin("android")
- id("kotlin-parcelize")
- id("zcash.android-build-conventions")
- id("wtf.emulator.gradle")
- id("zcash.emulator-wtf-conventions")
+ kotlin("multiplatform")
+ id("zcash.kotlin-multiplatform-build-conventions")
+ id("zcash.kotlin-multiplatform-jacoco-conventions")
+ id("zcash.dependency-conventions")
}
-android {
- // Force orchestrator to be used for this module, because we need the preference files
- // to be purged between tests
- defaultConfig {
- testInstrumentationRunnerArguments["clearPackageData"] = "true"
- }
-
- testOptions {
- execution = "ANDROIDX_TEST_ORCHESTRATOR"
- }
-}
-
-dependencies {
- implementation(libs.androidx.annotation)
- implementation(libs.kotlin.stdlib)
- implementation(libs.kotlinx.coroutines.android)
- implementation(libs.kotlinx.coroutines.core)
-
- androidTestImplementation(libs.bundles.androidx.test)
- androidTestImplementation(libs.kotlinx.coroutines.test)
-
- androidTestUtil(libs.androidx.test.orchestrator) {
- artifact {
- type = "apk"
+kotlin {
+ jvm()
+ sourceSets {
+ getByName("commonMain") {
+ dependencies {
+ api(libs.kotlinx.coroutines.core)
+ }
+ }
+ getByName("commonTest") {
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ }
+ getByName("jvmMain") {
+ dependencies {
+ }
+ }
+ getByName("jvmTest") {
+ dependencies {
+ implementation(kotlin("test"))
+ }
}
}
}
diff --git a/spackle-lib/gradle.lockfile b/spackle-lib/gradle.lockfile
new file mode 100644
index 00000000..9e59ebdf
--- /dev/null
+++ b/spackle-lib/gradle.lockfile
@@ -0,0 +1,32 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+net.java.dev.jna:jna:5.6.0=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.6.20=kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:1.6.20=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-script-runtime:1.6.20=kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-scripting-common:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-scripting-jvm:1.6.20=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain
+org.jetbrains.kotlin:kotlin-stdlib-common:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-stdlib:1.6.20=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlin:kotlin-test-annotations-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-common:1.6.20=commonTestImplementationDependenciesMetadata
+org.jetbrains.kotlin:kotlin-test-junit:1.6.20=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlin:kotlin-test:1.6.20=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:atomicfu:0.17.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.1=jvmCompileClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.6.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1=commonTestImplementationDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath
+org.jetbrains:annotations:13.0=allSourceSetsCompileDependenciesMetadata,allSourceSetsRuntimeDependenciesMetadata,commonMainApiDependenciesMetadata,commonTestApiDependenciesMetadata,jvmCompileClasspath,jvmMainApiDependenciesMetadata,jvmRuntimeClasspath,jvmTestApiDependenciesMetadata,jvmTestCompileClasspath,jvmTestImplementationDependenciesMetadata,jvmTestRuntimeClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath
+empty=archives,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonMainRuntimeOnlyDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,commonTestRuntimeOnlyDependenciesMetadata,default,jvmMainCompileOnlyDependenciesMetadata,jvmMainImplementationDependenciesMetadata,jvmMainIntransitiveDependenciesMetadata,jvmMainRuntimeOnlyDependenciesMetadata,jvmTestCompileOnlyDependenciesMetadata,jvmTestIntransitiveDependenciesMetadata,jvmTestRuntimeOnlyDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDef,kotlinScriptDefExtensions,testKotlinScriptDef,testKotlinScriptDefExtensions
diff --git a/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/IndexTest.kt b/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/IndexTest.kt
deleted file mode 100644
index 32c08262..00000000
--- a/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/IndexTest.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package co.electriccoin.zcash.spackle.model
-
-import androidx.test.filters.SmallTest
-import org.junit.Test
-
-class IndexTest {
- @Test(expected = IllegalArgumentException::class)
- @SmallTest
- fun out_of_bounds() {
- Index(-1)
- }
-}
diff --git a/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/ProgressTest.kt b/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/ProgressTest.kt
deleted file mode 100644
index bd19ea40..00000000
--- a/spackle-lib/src/androidTest/java/co/electriccoin/zcash/spackle/model/ProgressTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package co.electriccoin.zcash.spackle.model
-
-import androidx.test.filters.SmallTest
-import org.junit.Test
-
-class ProgressTest {
-
- @Test(expected = IllegalArgumentException::class)
- @SmallTest
- fun last_greater_than_zero() {
- Progress(current = Index(0), last = Index(0))
- }
-
- @Test(expected = IllegalArgumentException::class)
- @SmallTest
- fun last_greater_or_equal_to_current() {
- Progress(current = Index(5), last = Index(4))
- }
-}
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/LazyWithArgument.kt b/spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/LazyWithArgument.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/LazyWithArgument.kt
rename to spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/LazyWithArgument.kt
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/SuspendingLazy.kt b/spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/SuspendingLazy.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/SuspendingLazy.kt
rename to spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/SuspendingLazy.kt
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/model/Index.kt b/spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/model/Index.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/model/Index.kt
rename to spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/model/Index.kt
diff --git a/spackle-lib/src/main/java/co/electriccoin/zcash/spackle/model/Progress.kt b/spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/model/Progress.kt
similarity index 100%
rename from spackle-lib/src/main/java/co/electriccoin/zcash/spackle/model/Progress.kt
rename to spackle-lib/src/commonMain/kotlin/co/electriccoin/zcash/spackle/model/Progress.kt
diff --git a/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/IndexTest.kt b/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/IndexTest.kt
new file mode 100644
index 00000000..d35f4055
--- /dev/null
+++ b/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/IndexTest.kt
@@ -0,0 +1,13 @@
+package co.electriccoin.zcash.spackle.model
+
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+
+class IndexTest {
+ @Test
+ fun out_of_bounds() {
+ assertFailsWith(IllegalArgumentException::class) {
+ Index(-1)
+ }
+ }
+}
diff --git a/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/ProgressTest.kt b/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/ProgressTest.kt
new file mode 100644
index 00000000..fb689b87
--- /dev/null
+++ b/spackle-lib/src/commonTest/kotlin/co/electriccoin/zcash/spackle/model/ProgressTest.kt
@@ -0,0 +1,21 @@
+package co.electriccoin.zcash.spackle.model
+
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+
+class ProgressTest {
+
+ @Test
+ fun last_greater_than_zero() {
+ assertFailsWith(IllegalArgumentException::class) {
+ Progress(current = Index(0), last = Index(0))
+ }
+ }
+
+ @Test
+ fun last_greater_or_equal_to_current() {
+ assertFailsWith(IllegalArgumentException::class) {
+ Progress(current = Index(5), last = Index(4))
+ }
+ }
+}
diff --git a/spackle-lib/src/jvmMain/kotlin/co/electriccoin/zcash/spackle/io/FileExt.kt b/spackle-lib/src/jvmMain/kotlin/co/electriccoin/zcash/spackle/io/FileExt.kt
new file mode 100644
index 00000000..6f744b69
--- /dev/null
+++ b/spackle-lib/src/jvmMain/kotlin/co/electriccoin/zcash/spackle/io/FileExt.kt
@@ -0,0 +1,87 @@
+@file:Suppress("TooManyFunctions")
+
+package co.electriccoin.zcash.spackle.io
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+import java.util.UUID
+
+suspend fun File.existsSuspend() = withContext(Dispatchers.IO) {
+ exists()
+}
+
+suspend fun File.mkdirsSuspend() = withContext(Dispatchers.IO) {
+ mkdirs()
+}
+
+suspend fun File.isDirectorySuspend() = withContext(Dispatchers.IO) {
+ isDirectory
+}
+
+suspend fun File.isFileSuspend() = withContext(Dispatchers.IO) {
+ isFile
+}
+
+suspend fun File.canWriteSuspend() = withContext(Dispatchers.IO) {
+ canWrite()
+}
+
+suspend fun File.deleteSuspend() = withContext(Dispatchers.IO) {
+ delete()
+}
+
+suspend fun File.renameToSuspend(destination: File) = withContext(Dispatchers.IO) {
+ renameTo(destination)
+}
+
+suspend fun File.listFilesSuspend() = withContext(Dispatchers.IO) {
+ listFiles()
+}
+
+/**
+ * Given an ultimate output file destination, this generates a temporary file that [action] can write to. After action
+ * is complete, the temp file is renamed to the expected output destination. Depending on the underlying filesystem,
+ * this should effectively ensure that the file is perceived as being written atomically.
+ *
+ * @receiver Ultimate file that we desire to write to. Must be a file and not a directory.
+ * @param action Action to perform on the file, specifically this should be writing data. This action should not
+ * delete, rename, or do other operations in the filesystem.
+ */
+suspend fun File.writeAtomically(action: (suspend (File) -> Unit)) {
+ val tempFile = withContext(Dispatchers.IO) {
+ File(parentFile, name.newTempFileName()).also {
+ it.deleteOnExit()
+ }
+ }
+
+ var isWriteSuccessful = false
+
+ try {
+ action(tempFile)
+ isWriteSuccessful = true
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ tempFile.deleteSuspend()
+ throw e
+ } finally {
+ if (isWriteSuccessful) {
+ tempFile.moveTo(destination = this)
+ }
+ }
+}
+
+private suspend fun File.moveTo(destination: File) {
+ val isRenameSuccessful = renameToSuspend(destination)
+
+ if (!isRenameSuccessful) {
+ if (existsSuspend()) {
+ throw IOException("Couldn't move file $path to ${destination.path}")
+ }
+
+ // Otherwise no data was written, so there's no file to rename.
+ }
+}
+
+// Note that adding uuid and .tmp could theoretically go past file name length limits on some filesystems
+private fun String.newTempFileName() = "$this-${UUID.randomUUID()}.tmp"
diff --git a/spackle-lib/src/jvmTest/kotlin/co/electriccoin/zcash/spackle/io/WriteAtomicallyTest.kt b/spackle-lib/src/jvmTest/kotlin/co/electriccoin/zcash/spackle/io/WriteAtomicallyTest.kt
new file mode 100644
index 00000000..9df28f52
--- /dev/null
+++ b/spackle-lib/src/jvmTest/kotlin/co/electriccoin/zcash/spackle/io/WriteAtomicallyTest.kt
@@ -0,0 +1,62 @@
+package co.electriccoin.zcash.spackle.io
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import java.io.File
+import java.util.UUID
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class WriteAtomicallyTest {
+ // Putting in the build directory so that it doesn't show up as dirty in git
+ private fun newFile() = File(File("build"), "atomic_file_test-${UUID.randomUUID()}")
+
+ @Test
+ fun `file has temp name`() = runTest {
+ val testFile = newFile()
+ try {
+ testFile.writeAtomically {
+ it.writeText("test text")
+ assertNotEquals(testFile.name, it.name)
+ }
+ } finally {
+ testFile.delete()
+ }
+ }
+
+ @Test
+ fun `temp file deleted`() = runTest {
+ val testFile = newFile()
+ try {
+ var tempFile: File? = null
+
+ testFile.writeAtomically {
+ tempFile = it
+ it.writeText("test text")
+ }
+
+ assertNotNull(tempFile)
+ assertFalse(tempFile!!.exists())
+ } finally {
+ testFile.delete()
+ }
+ }
+
+ @Test
+ fun `file is renamed`() = runTest {
+ val testFile = newFile()
+ try {
+ testFile.writeAtomically {
+ it.writeText("test text")
+ }
+
+ assertTrue(testFile.exists())
+ } finally {
+ testFile.delete()
+ }
+ }
+}
diff --git a/spackle-lib/src/main/AndroidManifest.xml b/spackle-lib/src/main/AndroidManifest.xml
deleted file mode 100644
index d961bb5f..00000000
--- a/spackle-lib/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
diff --git a/ui-design-lib/build.gradle.kts b/ui-design-lib/build.gradle.kts
index 8c6450e9..5b8b898c 100644
--- a/ui-design-lib/build.gradle.kts
+++ b/ui-design-lib/build.gradle.kts
@@ -33,7 +33,7 @@ dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
- implementation(projects.spackleLib)
+ implementation(projects.spackleAndroidLib)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.androidx.compose.test.junit)
diff --git a/ui-design-lib/proguard-consumer.txt b/ui-design-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts
index 15d04d18..2548920a 100644
--- a/ui-lib/build.gradle.kts
+++ b/ui-lib/build.gradle.kts
@@ -74,11 +74,12 @@ dependencies {
implementation(libs.zxing)
implementation(projects.buildInfoLib)
+ implementation(projects.crashAndroidLib)
implementation(projects.preferenceApiLib)
implementation(projects.preferenceImplAndroidLib)
implementation(projects.sdkExtLib)
implementation(projects.sdkExtUiLib)
- implementation(projects.spackleLib)
+ implementation(projects.spackleAndroidLib)
implementation(projects.uiDesignLib)
androidTestImplementation(projects.testLib)
diff --git a/ui-lib/proguard-consumer.txt b/ui-lib/proguard-consumer.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanPermissionGrantedViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanPermissionGrantedViewTest.kt
index d0684b6b..3a244baf 100644
--- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanPermissionGrantedViewTest.kt
+++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanPermissionGrantedViewTest.kt
@@ -16,6 +16,7 @@ import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.TestScanActivity
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
@@ -44,6 +45,8 @@ class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
@Test
@MediumTest
+ // https://github.com/zcash/secant-android-wallet/issues/447
+ @Ignore
fun check_all_ui_elements_displayed() {
composeTestRule.waitForIdle()
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt
index 1fa66af9..7fc4df62 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
@@ -28,6 +29,7 @@ import cash.z.ecc.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.send
import cash.z.ecc.sdk.type.fromResources
+import co.electriccoin.zcash.spackle.EmulatorWtfUtil
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.ui.design.compat.FontCompat
import co.electriccoin.zcash.ui.design.component.GradientSurface
@@ -325,13 +327,22 @@ class MainActivity : ComponentActivity() {
if (null == walletSnapshot) {
// Display loading indicator
} else {
+ val context = LocalContext.current
+
+ // We might eventually want to check the debuggable property of the manifest instead
+ // of relying on BuildConfig.
+ val isDebugMenuEnabled = BuildConfig.DEBUG &&
+ !FirebaseTestLabUtil.isFirebaseTestLab(context) &&
+ !EmulatorWtfUtil.isEmulatorWtf(context)
+
Home(
walletSnapshot,
walletViewModel.transactionSnapshot.collectAsState().value,
goScan = goScan,
goRequest = goRequest,
goSend = goSend,
- goProfile = goProfile
+ goProfile = goProfile,
+ isDebugMenuEnabled = isDebugMenuEnabled
)
reportFullyDrawn()
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/Constants.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/Constants.kt
index bd554be9..600c96bf 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/Constants.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/Constants.kt
@@ -1,4 +1,6 @@
package co.electriccoin.zcash.ui.common
+import kotlin.time.Duration.Companion.seconds
+
// Recommended timeout for Android configuration changes to keep Kotlin Flow from restarting
-const val ANDROID_STATE_FLOW_TIMEOUT_MILLIS = 5000L
+val ANDROID_STATE_FLOW_TIMEOUT = 5.seconds
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 1ae8863e..c8d29e45 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
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCodeScanner
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -16,12 +19,17 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.sdk.ext.ui.model.toZecString
import cash.z.ecc.sdk.model.total
+import co.electriccoin.zcash.crash.android.CrashReporter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
@@ -33,6 +41,7 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import co.electriccoin.zcash.ui.screen.home.model.totalBalance
+import java.lang.RuntimeException
@Preview
@Composable
@@ -45,7 +54,8 @@ fun ComposablePreview() {
goScan = {},
goProfile = {},
goSend = {},
- goRequest = {}
+ goRequest = {},
+ isDebugMenuEnabled = false
)
}
}
@@ -60,10 +70,11 @@ fun Home(
goScan: () -> Unit,
goProfile: () -> Unit,
goSend: () -> Unit,
- goRequest: () -> Unit
+ goRequest: () -> Unit,
+ isDebugMenuEnabled: Boolean
) {
Scaffold(topBar = {
- HomeTopAppBar()
+ HomeTopAppBar(isDebugMenuEnabled)
}) {
HomeMainContent(
walletSnapshot,
@@ -77,8 +88,46 @@ fun Home(
}
@Composable
-private fun HomeTopAppBar() {
- SmallTopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
+private fun HomeTopAppBar(isDebugMenuEnabled: Boolean) {
+ SmallTopAppBar(
+ title = { Text(text = stringResource(id = R.string.app_name)) },
+ actions = {
+ if (isDebugMenuEnabled) {
+ DebugMenu()
+ }
+ }
+ )
+}
+
+@Composable
+private fun DebugMenu() {
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ IconButton(onClick = { expanded = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = null)
+ }
+
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Throw Uncaught Exception") },
+ onClick = {
+ // Supposed to be generic, for manual debugging only
+ @Suppress("TooGenericExceptionThrown")
+ throw RuntimeException("Manually crashed from debug menu")
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Report Caught Exception") },
+ 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"))
+ expanded = false
+ }
+ )
+ }
}
@Suppress("LongParameterList")
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/viewmodel/WalletViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/viewmodel/WalletViewModel.kt
index 8da09508..0e6e7eb5 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/viewmodel/WalletViewModel.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/viewmodel/WalletViewModel.kt
@@ -15,7 +15,7 @@ import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.sdk.model.WalletAddresses
-import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
+import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceKeys
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
@@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
@@ -57,7 +58,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
*/
val synchronizer = walletCoordinator.synchronizer.stateIn(
viewModelScope,
- SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@@ -80,7 +81,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}.stateIn(
viewModelScope,
- SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
@@ -96,7 +97,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
DerivationTool.deriveSpendingKeys(bip39Seed, it.network)[0]
}.stateIn(
viewModelScope,
- SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@@ -106,7 +107,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
.flatMapConcat { it.toWalletSnapshot() }
.stateIn(
viewModelScope,
- SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
@@ -116,7 +117,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
.filterNotNull()
.flatMapConcat { it.toTransactions() }
.stateIn(
- viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
emptyList()
)
@@ -125,7 +126,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
.filterIsInstance()
.map { WalletAddresses.new(it.persistableWallet) }
.stateIn(
- viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt
index fa6342d6..37fa0b7d 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt
@@ -6,10 +6,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.ext.collectWith
-import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
+import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
@@ -31,7 +32,7 @@ class RestoreViewModel(application: Application, savedStateHandle: SavedStateHan
emit(CompleteWordSetState.Loaded(TreeSet(completeWordList)))
}.stateIn(
viewModelScope,
- SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
+ SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
CompleteWordSetState.Loading
)
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/CrashInfo.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/CrashInfo.kt
index e91834ea..52205172 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/CrashInfo.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/CrashInfo.kt
@@ -1,21 +1,25 @@
package co.electriccoin.zcash.ui.screen.support.model
+import android.content.Context
+import co.electriccoin.zcash.crash.ExceptionPath
+import co.electriccoin.zcash.crash.ReportedException
+import co.electriccoin.zcash.crash.android.getExceptionDirectory
+import co.electriccoin.zcash.crash.new
+import co.electriccoin.zcash.spackle.io.listFilesSuspend
import kotlinx.datetime.Instant
+import java.io.File
-class CrashInfo(val timestamp: Instant, val isUncaught: Boolean, val className: String, val stacktrace: String) {
+class CrashInfo(val exceptionClassName: String, val isUncaught: Boolean, val timestamp: Instant) {
fun toSupportString() = buildString {
appendLine("Exception")
+ appendLine(" Class name: $exceptionClassName")
appendLine(" Is uncaught: $isUncaught")
appendLine(" Timestamp: $timestamp")
- appendLine(" Class name: $className")
// For now, don't include the stacktrace. It'll be too long for the emails we want to generate
}
- companion object {
- // TODO [#303]: Implement returning some number of recent crashes
- suspend fun all(): List = emptyList()
- }
+ companion object
}
fun List.toCrashSupportString() = buildString {
@@ -25,3 +29,18 @@ fun List.toCrashSupportString() = buildString {
appendLine(it.toSupportString())
}
}
+
+// If you change this, be sure to update the test case under /docs/testing/manual_testing/Contact Support.md
+private const val MAX_EXCEPTIONS_TO_REPORT = 5
+
+suspend fun CrashInfo.Companion.all(context: Context): List {
+ val exceptionDirectory = ExceptionPath.getExceptionDirectory(context) ?: return emptyList()
+ val filesList: List = exceptionDirectory.listFilesSuspend().toList()
+ return filesList
+ .mapNotNull {
+ ReportedException.new(it)
+ }.sortedBy { it.time }
+ .reversed()
+ .take(MAX_EXCEPTIONS_TO_REPORT)
+ .map { CrashInfo(it.exceptionClassName, it.isUncaught, it.time) }
+}
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/SupportInfo.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/SupportInfo.kt
index 24614825..e2a3b0b2 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/SupportInfo.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/model/SupportInfo.kt
@@ -70,7 +70,7 @@ data class SupportInfo(
DeviceInfo.new(),
EnvironmentInfo.new(applicationContext),
PermissionInfo.all(applicationContext),
- CrashInfo.all()
+ CrashInfo.all(context)
)
}
}
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/viewmodel/SupportViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/viewmodel/SupportViewModel.kt
index 5a5d10e6..78de627a 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/viewmodel/SupportViewModel.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/support/viewmodel/SupportViewModel.kt
@@ -3,16 +3,19 @@ package co.electriccoin.zcash.ui.screen.support.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
+import kotlin.time.Duration
class SupportViewModel(application: Application) : AndroidViewModel(application) {
// Technically, some of the support info could be invalidated after a configuration change,
// such as the user's current locale. However it really doesn't matter here since all we
// care about is capturing a snapshot of the app, OS, and device state.
val supportInfo: StateFlow = flow { emit(SupportInfo.new(application)) }
- .stateIn(viewModelScope, SharingStarted.Eagerly, null)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT, Duration.ZERO), null)
}