- 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:
parent
94d259d5b0
commit
9267e75cb8
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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" />
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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>
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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))
|
||||
}
|
|
@ -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)" }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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$
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
|
||||
import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider
|
||||
|
||||
class CrashProcessNameContentProvider : AbstractProcessNameContentProvider()
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="co_electriccoin_zcash_crash_process_name_suffix">:crash</string>
|
||||
</resources>
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@ data class SupportInfo(
|
|||
DeviceInfo.new(),
|
||||
EnvironmentInfo.new(applicationContext),
|
||||
PermissionInfo.all(applicationContext),
|
||||
CrashInfo.all()
|
||||
CrashInfo.all(context)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue