[#303] Write crash logs on external storage (#429)

- Added automatic exception logging, registered in the Application object
 - The design sends the exception to a second process, as the main process could be in a bad state when crashing. If we ever encounter problems with this design, it is easily toggleable to turn off with a boolean resource
 - Reading the process name is a bit complex on older Android versions, so we leverage a ContentProvider (which runs prior to Application.onCreate()) to get the process name
 - Added a simple logging mechanism for multiprocess and multithread log messages
 - Refactored spackle-lib into spackle-lib (multiplatform) and spackle-android-lib

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Carter Jernigan 2022-05-31 12:38:02 -04:00 committed by GitHub
parent 94d259d5b0
commit 9267e75cb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 1761 additions and 150 deletions

View File

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

View File

@ -4,12 +4,14 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="assemble" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list />
<list>
<option value="assemble" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>

View File

@ -4,12 +4,14 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="assembleDebug assembleZcashmainnetDebug assembleZcashtestnetDebug assembleAndroidTest" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list />
<list>
<option value="assembleAndroidTest" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>

View File

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="kotlin-test" type="GradleRunConfiguration" factoryName="Gradle">
<configuration default="false" name="check" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
@ -10,10 +10,10 @@
</option>
<option name="taskNames">
<list>
<option value=":preference-api-lib:check" />
<option value="check" />
</list>
</option>
<option name="vmOptions" value="" />
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>

View File

@ -0,0 +1,27 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="check connectedCheck detektAll ktlint lint" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="check" />
<option value="connectedCheck" />
<option value="detektAll" />
<option value="ktlint" />
<option value="lint" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="connectedCheck" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="connectedCheck" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="spackle-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.spackle-lib" />
<configuration default="false" name="crash-android-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.crash-android-lib" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
@ -13,8 +13,6 @@
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />

View File

@ -4,12 +4,14 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="ktlintFormat" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list />
<list>
<option value="ktlintFormat" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>

View File

@ -0,0 +1,55 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="spackle-android-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.spackle-android-lib" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="RETENTION_ENABLED" value="No" />
<option name="RETENTION_MAX_SNAPSHOTS" value="2" />
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="testDebugWithEmulatorWtf" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="testDebugWithEmulatorWtf" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

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

View File

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash">
<application
android:name=".app.AppImpl"
android:name=".app.ZcashApplication"
android:allowBackup="false"
android:label="@string/app_name">
@ -13,9 +12,9 @@
clients. -->
<activity-alias
android:name=".LauncherActivity"
android:exported="true"
android:label="@string/app_name"
android:targetActivity="co.electriccoin.zcash.ui.MainActivity"
android:exported="true">
android:targetActivity="co.electriccoin.zcash.ui.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.crash.test">
<application/>
</manifest>

View File

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

View File

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

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.crash.android">
<application>
<provider
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
android:name="co.electriccoin.zcash.crash.android.internal.CrashProcessNameContentProvider"
android:exported="false"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:process="@string/co_electriccoin_zcash_crash_process_name_suffix"/>
<receiver
android:name="co.electriccoin.zcash.crash.android.internal.ExceptionReceiver"
android:exported="false"
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
android:process="@string/co_electriccoin_zcash_crash_process_name_suffix" />
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package co.electriccoin.zcash.crash.android.internal
import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider
class CrashProcessNameContentProvider : AbstractProcessNameContentProvider()

View File

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

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
</resources>

View File

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

View File

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

35
crash-lib/gradle.lockfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.spackle">
<application>
<provider
android:name=".process.internal.DefaultProcessContentProvider"
android:authorities="${applicationId}.co.electriccoin.zcash.spackle"
android:exported="false" />
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <Process> <Thread> <Class>.<method>(): <message>
*/
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")
}
}

View File

@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<out String>?): Int {
throw UnsupportedOperationException()
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException()
}
companion object {
internal fun getProcessNameLegacy(context: Context, info: ProviderInfo) =
info.processName ?: context.applicationInfo.processName ?: context.packageName
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.spackle">
<application/>
</manifest>

View File

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

View File

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -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<SecretState.Ready>()
.map { WalletAddresses.new(it.persistableWallet) }
.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)

View File

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

View File

@ -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<CrashInfo> = emptyList()
}
companion object
}
fun List<CrashInfo>.toCrashSupportString() = buildString {
@ -25,3 +29,18 @@ fun List<CrashInfo>.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<CrashInfo> {
val exceptionDirectory = ExceptionPath.getExceptionDirectory(context) ?: return emptyList()
val filesList: List<File> = 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) }
}

View File

@ -70,7 +70,7 @@ data class SupportInfo(
DeviceInfo.new(),
EnvironmentInfo.new(applicationContext),
PermissionInfo.all(applicationContext),
CrashInfo.all()
CrashInfo.all(context)
)
}
}

View File

@ -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<SupportInfo?> = flow<SupportInfo?> { emit(SupportInfo.new(application)) }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT, Duration.ZERO), null)
}