parent
a5d38c6bf9
commit
6e85764f74
|
@ -6,6 +6,8 @@
|
|||
# UPLOAD_KEYSTORE_PASSWORD - The password for UPLOAD_KEYSTORE_BASE_64
|
||||
# UPLOAD_KEY_ALIAS - The key alias inside UPLOAD_KEYSTORE_BASE_64
|
||||
# UPLOAD_KEY_ALIAS_PASSWORD - The password for the key alias
|
||||
# FIREBASE_DEBUG_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for debug builds
|
||||
# FIREBASE_RELEASE_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for release builds
|
||||
|
||||
name: Deploy
|
||||
|
||||
|
@ -70,6 +72,17 @@ jobs:
|
|||
id: setup
|
||||
timeout-minutes: 12
|
||||
uses: ./.github/actions/setup
|
||||
- name: Export Google Services JSON
|
||||
env:
|
||||
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
|
||||
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
|
||||
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p app/src/debug/
|
||||
mkdir -p app/src/release/
|
||||
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
|
||||
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
|
||||
- name: Authenticate to Google Cloud for Google Play
|
||||
id: auth_google_play
|
||||
uses: google-github-actions/auth@ef5d53e30bbcd8d0836f4288f5e50ff3e086997d
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
# EMULATOR_WTF_API_KEY - Optional API key for emulator.wtf
|
||||
# FIREBASE_TEST_LAB_SERVICE_ACCOUNT - Email address of Firebase Test Lab service account
|
||||
# FIREBASE_TEST_LAB_WORKLOAD_IDENTITY_PROVIDER - Workload identity provider to generate temporary service account key
|
||||
# FIREBASE_DEBUG_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for debug builds
|
||||
# FIREBASE_RELEASE_JSON_BASE64 - Optional JSON to enable Firebase (e.g. Crashlytics) for release builds
|
||||
|
||||
# Expected variables
|
||||
# FIREBASE_TEST_LAB_PROJECT - Firebase Test Lab project name
|
||||
|
@ -392,6 +394,17 @@ jobs:
|
|||
id: setup
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/setup
|
||||
- name: Export Google Services JSON
|
||||
env:
|
||||
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
|
||||
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
|
||||
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p app/src/debug/
|
||||
mkdir -p app/src/release/
|
||||
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
|
||||
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
|
||||
- name: Build
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
|
@ -429,6 +442,17 @@ jobs:
|
|||
id: setup
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/setup
|
||||
- name: Export Google Services JSON
|
||||
env:
|
||||
FIREBASE_DEBUG_JSON_BASE64: ${{ secrets.FIREBASE_DEBUG_JSON_BASE64 }}
|
||||
FIREBASE_RELEASE_JSON_BASE64: ${{ secrets.FIREBASE_RELEASE_JSON_BASE64 }}
|
||||
if: "${{ env.FIREBASE_DEBUG_JSON_BASE64 != '' && env.FIREBASE_RELEASE_JSON_BASE64 != '' }}"
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p app/src/debug/
|
||||
mkdir -p app/src/release/
|
||||
echo ${FIREBASE_DEBUG_JSON_BASE64} | base64 --decode > app/src/debug/google-services.json
|
||||
echo ${FIREBASE_RELEASE_JSON_BASE64} | base64 --decode > app/src/release/google-services.json
|
||||
# A fake signing key to satisfy creating a "release" build
|
||||
- name: Export Signing Key
|
||||
env:
|
||||
|
|
|
@ -21,3 +21,4 @@ local.properties
|
|||
/.idea/assetWizardSettings.xml
|
||||
/.idea/inspectionProfiles/Project_Default.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
google-services.json
|
||||
|
|
|
@ -33,6 +33,7 @@ If you plan to fork the project to create a new app of your own, please make the
|
|||
1. Under [app/build.gradle.kts](app/build.gradle.kts), change the package name of the application
|
||||
1. Optional
|
||||
1. Configure secrets for [Continuous Integration](docs/CI.md).
|
||||
1. Configure Firebase API keys and placing them under `app/src/debug/google-services.json` and `app/src/release/google-services.json`
|
||||
|
||||
# Known Issues
|
||||
1. Intel-based machines may have trouble building in Android Studio. The workaround is to add the following line to `~/.gradle/gradle.properties` `ZCASH_IS_DEPENDENCY_LOCKING_ENABLED=false`. See [#420](https://github.com/zcash/secant-android-wallet/issues/420) for more information.
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import com.android.build.api.variant.BuildConfigField
|
||||
import com.android.build.api.variant.ResValue
|
||||
import java.util.Locale
|
||||
|
||||
plugins {
|
||||
|
@ -10,6 +12,26 @@ plugins {
|
|||
id("secant.emulator-wtf-conventions")
|
||||
}
|
||||
|
||||
val hasFirebaseApiKeys = run {
|
||||
val srcDir = File(project.projectDir, "src")
|
||||
val releaseApiKey = File(File(srcDir, "release"), "google-services.json")
|
||||
val debugApiKey = File(File(srcDir, "debug"), "google-services.json")
|
||||
|
||||
val result = releaseApiKey.exists() && debugApiKey.exists()
|
||||
|
||||
if (!result) {
|
||||
project.logger.info("Firebase API keys not found. Crashlytics will not be enabled. To enable " +
|
||||
"Firebase, add the API keys for ${releaseApiKey.path} and ${debugApiKey.path}.")
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
if (hasFirebaseApiKeys) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
apply(plugin = "com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val packageName = project.property("ZCASH_RELEASE_PACKAGE_NAME").toString()
|
||||
|
||||
val testnetNetworkName = "Testnet"
|
||||
|
@ -38,8 +60,8 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
flavorDimensions.add("network")
|
||||
|
@ -112,13 +134,6 @@ android {
|
|||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
all {
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"IS_STRICT_MODE_CRASH_ENABLED",
|
||||
project.property("IS_CRASH_ON_STRICT_MODE_VIOLATION").toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve final app name
|
||||
|
@ -153,8 +168,6 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.core)
|
||||
|
@ -165,6 +178,8 @@ dependencies {
|
|||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.zcash.sdk) // just to configure logging
|
||||
implementation(projects.crashAndroidLib)
|
||||
implementation(projects.preferenceApiLib)
|
||||
implementation(projects.preferenceImplAndroidLib)
|
||||
implementation(projects.spackleAndroidLib)
|
||||
implementation(projects.uiLib)
|
||||
|
||||
|
@ -190,6 +205,21 @@ val googlePlayServiceKeyFilePath = project.property("ZCASH_GOOGLE_PLAY_SERVICE_K
|
|||
androidComponents {
|
||||
onVariants { variant ->
|
||||
for (output in variant.outputs) {
|
||||
variant.buildConfigFields.put(
|
||||
"IS_STRICT_MODE_CRASH_ENABLED",
|
||||
BuildConfigField(
|
||||
type = "boolean",
|
||||
value = project.property("IS_CRASH_ON_STRICT_MODE_VIOLATION").toString(),
|
||||
comment = null
|
||||
)
|
||||
)
|
||||
|
||||
variant.resValues.put(
|
||||
// Key matches the one in crash-android-lib/src/res/values/bools.xml
|
||||
variant.makeResValueKey("bool", "co_electriccoin_zcash_crash_is_firebase_enabled"),
|
||||
ResValue(value = hasFirebaseApiKeys.toString())
|
||||
)
|
||||
|
||||
if (googlePlayServiceKeyFilePath.isNotEmpty()) {
|
||||
// Update the versionName to reflect bumps in versionCode
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package co.electriccoin.zcash.app
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
open class CoroutineApplication : Application() {
|
||||
protected lateinit var applicationScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
applicationScope.coroutineContext.cancel()
|
||||
super.onTerminate()
|
||||
}
|
||||
}
|
|
@ -1,29 +1,58 @@
|
|||
package co.electriccoin.zcash.app
|
||||
|
||||
import android.app.Application
|
||||
import co.electriccoin.zcash.crash.android.CrashReporter
|
||||
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
|
||||
import co.electriccoin.zcash.spackle.StrictModeCompat
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("unused")
|
||||
class ZcashApplication : Application() {
|
||||
class ZcashApplication : CoroutineApplication() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
configureLogging()
|
||||
|
||||
configureStrictMode()
|
||||
|
||||
// Since analytics will need disk IO internally, we want this to be registered after strict
|
||||
// mode is configured to ensure none of that IO happens on the main thread
|
||||
configureAnalytics()
|
||||
}
|
||||
|
||||
private fun configureLogging() {
|
||||
Twig.initialize(applicationContext)
|
||||
Twig.info { "Starting application…" }
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictModeCompat.enableStrictMode(BuildConfig.IS_STRICT_MODE_CRASH_ENABLED)
|
||||
|
||||
// This is an internal API to the Zcash SDK to enable logging; it could change in the future
|
||||
cash.z.ecc.android.sdk.internal.Twig.enabled(true)
|
||||
} else {
|
||||
// In release builds, logs should be stripped by R8 rules
|
||||
Twig.assertLoggingStripped()
|
||||
}
|
||||
}
|
||||
|
||||
CrashReporter.register(this)
|
||||
private fun configureStrictMode() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictModeCompat.enableStrictMode(BuildConfig.IS_STRICT_MODE_CRASH_ENABLED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureAnalytics() {
|
||||
if (GlobalCrashReporter.register(this)) {
|
||||
applicationScope.launch {
|
||||
val prefs = StandardPreferenceSingleton.getInstance(applicationContext)
|
||||
StandardPreferenceKeys.IS_ANALYTICS_ENABLED.observe(prefs).collect {
|
||||
if (it) {
|
||||
GlobalCrashReporter.enable()
|
||||
} else {
|
||||
GlobalCrashReporter.disableAndDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,6 +89,8 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension(isLibrary: Boo
|
|||
ndkVersion = project.property("ANDROID_NDK_VERSION").toString()
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
|
||||
val javaVersion = JavaVersion.toVersion(project.property("ANDROID_JVM_TARGET").toString())
|
||||
sourceCompatibility = javaVersion
|
||||
targetCompatibility = javaVersion
|
||||
|
@ -184,6 +186,13 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension(isLibrary: Boo
|
|||
freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add(
|
||||
"coreLibraryDesugaring",
|
||||
"com.android.tools:desugar_jdk_libs:${project.property("CORE_LIBRARY_DESUGARING_VERSION")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
|
||||
|
|
|
@ -15,6 +15,45 @@ buildscript {
|
|||
lockAllConfigurations()
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
val isRepoRestrictionEnabled = true
|
||||
|
||||
val googleGroups = listOf<String>(
|
||||
"com.google.firebase",
|
||||
"com.google.gms",
|
||||
"com.google.android.gms"
|
||||
)
|
||||
|
||||
google {
|
||||
if (isRepoRestrictionEnabled) {
|
||||
content {
|
||||
googleGroups.forEach { includeGroup(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// We don't use mavenCentral now, but in the future we may want to use it for some dependencies
|
||||
// mavenCentral {
|
||||
// if (isRepoRestrictionEnabled) {
|
||||
// content {
|
||||
// googleGroups.forEach { excludeGroup(it) }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
gradlePluginPortal {
|
||||
if (isRepoRestrictionEnabled) {
|
||||
content {
|
||||
googleGroups.forEach { excludeGroup(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val crashlyticsVersion = project.property("FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION")
|
||||
classpath("com.google.firebase:firebase-crashlytics-gradle:$crashlyticsVersion")
|
||||
classpath("com.google.gms:google-services:${project.property("GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION")}")
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
|
|
|
@ -43,6 +43,7 @@ com.android:signflinger:7.4.0=classpath
|
|||
com.android:zipflinger:7.4.0=classpath
|
||||
com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin:0.42.0=classpath,classpathCopy,classpathCopy2
|
||||
com.github.ben-manes:gradle-versions-plugin:0.42.0=classpath
|
||||
com.google.android.gms:strict-version-matcher-plugin:1.2.4=classpath
|
||||
com.google.android:annotations:4.1.1.4=classpath
|
||||
com.google.api.grpc:proto-google-common-protos:2.0.1=classpath
|
||||
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
||||
|
@ -51,7 +52,10 @@ com.google.code.gson:gson:2.8.9=classpath
|
|||
com.google.crypto.tink:tink:1.3.0-rc2=classpath
|
||||
com.google.dagger:dagger:2.28.3=classpath
|
||||
com.google.errorprone:error_prone_annotations:2.4.0=classpath
|
||||
com.google.firebase:firebase-crashlytics-buildtools:2.9.2=classpath
|
||||
com.google.firebase:firebase-crashlytics-gradle:2.9.2=classpath
|
||||
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
|
||||
com.google.gms:google-services:4.3.15=classpath
|
||||
com.google.guava:failureaccess:1.0.1=classpath
|
||||
com.google.guava:guava:30.1-jre=classpath
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath
|
||||
|
|
|
@ -20,17 +20,17 @@ android {
|
|||
testOptions {
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
|
||||
api(projects.crashLib)
|
||||
api(libs.androidx.annotation)
|
||||
api(projects.crashLib)
|
||||
|
||||
implementation(platform(libs.firebase.bom))
|
||||
|
||||
implementation(libs.firebase.crashlytics)
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.installations)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(projects.spackleAndroidLib)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
|
@ -0,0 +1,41 @@
|
|||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.filters.SmallTest
|
||||
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Components {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun process_names() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val pm = ApplicationProvider.getApplicationContext<Context>().packageManager
|
||||
val providerInfo = pm.getProviderInfoCompat(ComponentName(context, CrashProcessNameContentProvider::class.java))
|
||||
val receiverInfo = pm.getReceiverInfoCompat(ComponentName(context, ExceptionReceiver::class.java))
|
||||
|
||||
assertEquals(providerInfo.processName, receiverInfo.processName)
|
||||
assertTrue(providerInfo.processName.endsWith(GlobalCrashReporter.CRASH_PROCESS_NAME_SUFFIX))
|
||||
}
|
||||
}
|
||||
|
||||
private fun PackageManager.getProviderInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) {
|
||||
getProviderInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("Deprecation")
|
||||
getProviderInfo(componentName, 0)
|
||||
}
|
||||
|
||||
private fun PackageManager.getReceiverInfoCompat(componentName: ComponentName) = if (AndroidApiVersion.isAtLeastT) {
|
||||
getReceiverInfo(componentName, PackageManager.ComponentInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("Deprecation")
|
||||
getReceiverInfo(componentName, 0)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import co.electriccoin.zcash.crash.ReportableException
|
||||
import co.electriccoin.zcash.crash.fixture.ReportableExceptionFixture
|
|
@ -1,20 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
|
||||
<!-- For improved user privacy, don't allow Firebase to collect advertising IDs -->
|
||||
<meta-data
|
||||
android:name="google_analytics_adid_collection_enabled"
|
||||
android:value="false" />
|
||||
|
||||
<!-- We want better control over the timing of Firebase initialization -->
|
||||
<provider
|
||||
android:name="com.google.firebase.provider.FirebaseInitProvider"
|
||||
android:authorities="${applicationId}.firebaseinitprovider"
|
||||
tools:node="remove" />
|
||||
|
||||
<provider
|
||||
android:name=".internal.local.CrashProcessNameContentProvider"
|
||||
android:authorities="${applicationId}.co.electriccoin.zcash.crash"
|
||||
android: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"/>
|
||||
android:exported="false"
|
||||
android:process=":crash" />
|
||||
|
||||
<receiver
|
||||
android:name="co.electriccoin.zcash.crash.android.internal.ExceptionReceiver"
|
||||
android:exported="false"
|
||||
android:name=".internal.local.ExceptionReceiver"
|
||||
android:enabled="@bool/co_electriccoin_zcash_crash_is_use_secondary_process"
|
||||
android:process="@string/co_electriccoin_zcash_crash_process_name_suffix" />
|
||||
android:exported="false"
|
||||
android:process=":crash" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
package co.electriccoin.zcash.crash.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import co.electriccoin.zcash.crash.ReportableException
|
||||
import co.electriccoin.zcash.crash.android.internal.AndroidExceptionReporter
|
||||
import co.electriccoin.zcash.crash.android.internal.AndroidUncaughtExceptionHandler
|
||||
import co.electriccoin.zcash.crash.android.internal.new
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object CrashReporter {
|
||||
|
||||
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
@Volatile
|
||||
private var applicationContext: Context? = null
|
||||
|
||||
/**
|
||||
* Call to register detection of uncaught exceptions.
|
||||
*
|
||||
* This must should only be called once for the entire lifetime of an application's process.
|
||||
*/
|
||||
@MainThread
|
||||
fun register(context: Context) {
|
||||
AndroidUncaughtExceptionHandler.register(context)
|
||||
|
||||
applicationContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught exception, e.g. within a try-catch.
|
||||
*
|
||||
* Be sure to call [register] before calling this method.
|
||||
*/
|
||||
@AnyThread
|
||||
fun reportCaughtException(exception: Throwable) {
|
||||
// This method relies on a global Context reference, because often Context is not available
|
||||
// in various places where we'd like to capture an exception from a try-catch.
|
||||
|
||||
applicationContext?.let {
|
||||
crashReportingScope.launch {
|
||||
AndroidExceptionReporter.reportException(it, ReportableException.new(it, exception, false))
|
||||
}
|
||||
} ?: run {
|
||||
Twig.warn { "Unable to log exception; Call `register(Context)` prior to reportCaughtException(Throwable)" }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package co.electriccoin.zcash.crash.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import co.electriccoin.zcash.crash.android.internal.CrashReporter
|
||||
import co.electriccoin.zcash.crash.android.internal.firebase.FirebaseCrashReporter
|
||||
import co.electriccoin.zcash.crash.android.internal.local.LocalCrashReporter
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import co.electriccoin.zcash.spackle.process.ProcessNameCompat
|
||||
import java.util.Collections
|
||||
|
||||
object GlobalCrashReporter {
|
||||
|
||||
internal const val CRASH_PROCESS_NAME_SUFFIX = ":crash" // $NON-NLS
|
||||
|
||||
private val intrinsicLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var registeredCrashReporters: List<CrashReporter>? = null
|
||||
|
||||
/**
|
||||
* Call to register detection of uncaught exceptions and enable reporting of caught exceptions.
|
||||
*
|
||||
* @return True if registration occurred and false if registration was skipped.
|
||||
*/
|
||||
@MainThread
|
||||
fun register(context: Context): Boolean {
|
||||
if (isCrashProcess(context)) {
|
||||
Twig.debug { "Skipping registration for $CRASH_PROCESS_NAME_SUFFIX process" } // $NON-NLS
|
||||
return false
|
||||
}
|
||||
|
||||
synchronized(intrinsicLock) {
|
||||
if (registeredCrashReporters == null) {
|
||||
registeredCrashReporters = Collections.synchronizedList(
|
||||
// To prevent a race condition, register the LocalCrashReporter first.
|
||||
// FirebaseCrashReporter does some asynchronous registration internally, while
|
||||
// LocalCrashReporter uses AndroidUncaughtExceptionHandler which needs to read
|
||||
// and write the default UncaughtExceptionHandler. The only way to ensure
|
||||
// interleaving doesn't happen is to register the LocalCrashReporter first.
|
||||
listOfNotNull(
|
||||
LocalCrashReporter.getInstance(context),
|
||||
FirebaseCrashReporter(context),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught exception, e.g. within a try-catch.
|
||||
*
|
||||
* Be sure to call [register] before calling this method.
|
||||
*/
|
||||
@AnyThread
|
||||
fun reportCaughtException(exception: Throwable) {
|
||||
registeredCrashReporters?.forEach { it.reportCaughtException(exception) }
|
||||
}
|
||||
|
||||
fun disableAndDelete() {
|
||||
registeredCrashReporters?.forEach { it.disableAndDelete() }
|
||||
}
|
||||
|
||||
fun enable() {
|
||||
registeredCrashReporters?.forEach { it.enable() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCrashProcess(context: Context) =
|
||||
ProcessNameCompat.getProcessName(context)
|
||||
.endsWith(GlobalCrashReporter.CRASH_PROCESS_NAME_SUFFIX)
|
|
@ -0,0 +1,22 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
|
||||
interface CrashReporter {
|
||||
|
||||
/**
|
||||
* Report a caught exception, e.g. within a try-catch.
|
||||
*/
|
||||
@AnyThread
|
||||
fun reportCaughtException(exception: Throwable)
|
||||
|
||||
/**
|
||||
* Enables crash reporting that may have privacy implications.
|
||||
*/
|
||||
fun enable()
|
||||
|
||||
/**
|
||||
* Disables reporting and deletes any data that may have privacy implications.
|
||||
*/
|
||||
fun disableAndDelete()
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package co.electriccoin.zcash.crash.android.internal.firebase
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.FirebaseApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object FirebaseAppCache {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var cachedFirebaseApp: FirebaseAppContainer? = null
|
||||
|
||||
fun peekFirebaseApp(): FirebaseApp? = cachedFirebaseApp?.firebaseApp
|
||||
|
||||
suspend fun getFirebaseApp(context: Context): FirebaseApp? {
|
||||
mutex.withLock {
|
||||
peekFirebaseApp()?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val firebaseAppContainer = getFirebaseAppContainer(context)
|
||||
|
||||
cachedFirebaseApp = firebaseAppContainer
|
||||
}
|
||||
|
||||
return peekFirebaseApp()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFirebaseAppContainer(context: Context): FirebaseAppContainer = withContext(Dispatchers.IO) {
|
||||
val firebaseApp = FirebaseApp.initializeApp(context)
|
||||
FirebaseAppContainer(firebaseApp)
|
||||
}
|
||||
|
||||
private class FirebaseAppContainer(val firebaseApp: FirebaseApp?)
|
|
@ -0,0 +1,129 @@
|
|||
@file:JvmName("FirebaseCrashReporterKt")
|
||||
|
||||
package co.electriccoin.zcash.crash.android.internal.firebase
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import co.electriccoin.zcash.crash.android.R
|
||||
import co.electriccoin.zcash.crash.android.internal.CrashReporter
|
||||
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
|
||||
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
||||
import co.electriccoin.zcash.spackle.SuspendingLazy
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
/**
|
||||
* Registers an exception handler with Firebase Crashlytics.
|
||||
*/
|
||||
internal class FirebaseCrashReporter(
|
||||
context: Context
|
||||
) : CrashReporter {
|
||||
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private val initFirebaseJob: Deferred<CrashReporter?> = analyticsScope.async {
|
||||
FirebaseCrashReporterImpl.getInstance(context)
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
override fun reportCaughtException(exception: Throwable) {
|
||||
initFirebaseJob.invokeOnCompletionWithResult {
|
||||
it?.reportCaughtException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable() {
|
||||
initFirebaseJob.invokeOnCompletionWithResult {
|
||||
it?.enable()
|
||||
}
|
||||
}
|
||||
|
||||
override fun disableAndDelete() {
|
||||
initFirebaseJob.invokeOnCompletionWithResult {
|
||||
it?.disableAndDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun <T> Deferred<T>.invokeOnCompletionWithResult(handler: (T) -> Unit) {
|
||||
invokeOnCompletion {
|
||||
handler(this.getCompleted())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an exception handler with Firebase Crashlytics.
|
||||
*/
|
||||
private class FirebaseCrashReporterImpl(
|
||||
private val firebaseCrashlytics: FirebaseCrashlytics,
|
||||
private val firebaseInstallations: FirebaseInstallations
|
||||
) : CrashReporter {
|
||||
|
||||
@AnyThread
|
||||
override fun reportCaughtException(exception: Throwable) {
|
||||
firebaseCrashlytics.recordException(exception)
|
||||
}
|
||||
|
||||
override fun enable() {
|
||||
firebaseCrashlytics.setCrashlyticsCollectionEnabled(true)
|
||||
}
|
||||
|
||||
override fun disableAndDelete() {
|
||||
firebaseCrashlytics.setCrashlyticsCollectionEnabled(false)
|
||||
firebaseCrashlytics.deleteUnsentReports()
|
||||
firebaseInstallations.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/*
|
||||
* Note there is a tradeoff with the suspending implementation. In order to avoid disk IO
|
||||
* on the main thread, there is a brief timing gap during application startup where very
|
||||
* early crashes may be missed. This is a tradeoff we are willing to make in order to avoid
|
||||
* ANRs.
|
||||
*/
|
||||
private val lazyWithArgument = SuspendingLazy<Context, CrashReporter?> {
|
||||
if (it.resources.getBoolean(R.bool.co_electriccoin_zcash_crash_is_firebase_enabled)) {
|
||||
|
||||
// Workaround for disk IO on main thread in Firebase initialization
|
||||
val firebaseApp = FirebaseAppCache.getFirebaseApp(it)
|
||||
if (firebaseApp == null) {
|
||||
Twig.warn { "Unable to initialize Crashlytics. FirebaseApp is null" }
|
||||
return@SuspendingLazy null
|
||||
}
|
||||
|
||||
val firebaseInstallations = FirebaseInstallations.getInstance(firebaseApp)
|
||||
val firebaseCrashlytics = FirebaseCrashlytics.getInstance().apply {
|
||||
setCustomKey(
|
||||
CrashlyticsUserProperties.IS_TEST,
|
||||
EmulatorWtfUtil.isEmulatorWtf(it) || FirebaseTestLabUtil.isFirebaseTestLab(it)
|
||||
)
|
||||
}
|
||||
|
||||
FirebaseCrashReporterImpl(firebaseCrashlytics, firebaseInstallations)
|
||||
} else {
|
||||
Twig.warn { "Unable to initialize Crashlytics. Configure API keys in the app module" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getInstance(context: Context): CrashReporter? {
|
||||
return lazyWithArgument.getInstance(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object CrashlyticsUserProperties {
|
||||
/**
|
||||
* Flags a crash as occurring in a test environment. Set automatically to detect Firebase Test Lab and emulator.wtf
|
||||
*/
|
||||
const val IS_TEST = "is_test" // $NON-NLS
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
|
@ -7,7 +7,7 @@ import co.electriccoin.zcash.crash.ReportableException
|
|||
import co.electriccoin.zcash.crash.android.getExceptionPath
|
||||
import co.electriccoin.zcash.crash.write
|
||||
|
||||
object AndroidExceptionReporter {
|
||||
internal object AndroidExceptionReporter {
|
||||
internal suspend fun reportException(context: Context, reportableException: ReportableException) {
|
||||
val exceptionPath = ExceptionPath.getExceptionPath(context, reportableException)
|
||||
?: return
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
|
@ -7,7 +7,7 @@ import co.electriccoin.zcash.spackle.getPackageInfoCompat
|
|||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
fun ReportableException.Companion.new(
|
||||
internal fun ReportableException.Companion.new(
|
||||
context: Context,
|
||||
throwable: Throwable,
|
||||
isUncaught: Boolean,
|
||||
|
@ -25,7 +25,7 @@ fun ReportableException.Companion.new(
|
|||
)
|
||||
}
|
||||
|
||||
fun ReportableException.toBundle() = Bundle().apply {
|
||||
internal fun ReportableException.toBundle() = Bundle().apply {
|
||||
// Although Exception is Serializable, some Kotlin Coroutines exception classes break this
|
||||
// API contract. Therefore we have to convert to a string here.
|
||||
putSerializable(ReportableException.EXTRA_STRING_CLASS_NAME, exceptionClass)
|
||||
|
@ -35,7 +35,7 @@ fun ReportableException.toBundle() = Bundle().apply {
|
|||
putLong(ReportableException.EXTRA_LONG_WALLTIME_MILLIS, time.toEpochMilliseconds())
|
||||
}
|
||||
|
||||
fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException {
|
||||
internal fun ReportableException.Companion.fromBundle(bundle: Bundle): ReportableException {
|
||||
val className = bundle.getString(EXTRA_STRING_CLASS_NAME)!!
|
||||
val trace = bundle.getString(EXTRA_STRING_TRACE)!!
|
||||
val appVersion = bundle.getString(EXTRA_STRING_APP_VERSION)!!
|
|
@ -1,16 +1,14 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import androidx.annotation.MainThread
|
||||
import co.electriccoin.zcash.crash.ReportableException
|
||||
import co.electriccoin.zcash.crash.android.R
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import co.electriccoin.zcash.spackle.process.ProcessNameCompat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class AndroidUncaughtExceptionHandler(
|
||||
internal class AndroidUncaughtExceptionHandler(
|
||||
context: Context,
|
||||
private val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
|
@ -44,17 +42,9 @@ class AndroidUncaughtExceptionHandler(
|
|||
check(Looper.myLooper() == Looper.getMainLooper()) { "Must be called from the main thread" }
|
||||
check(!isInitialized.getAndSet(true)) { "Uncaught exception handler can only be set once" }
|
||||
|
||||
if (isCrashProcess(context)) {
|
||||
Twig.debug { "Uncaught exception handler will not be registered in the crash handling process" }
|
||||
} else {
|
||||
Thread.getDefaultUncaughtExceptionHandler()?.let { previous ->
|
||||
Thread.setDefaultUncaughtExceptionHandler(AndroidUncaughtExceptionHandler(context, previous))
|
||||
}
|
||||
Thread.getDefaultUncaughtExceptionHandler()?.let { previous ->
|
||||
Thread.setDefaultUncaughtExceptionHandler(AndroidUncaughtExceptionHandler(context, previous))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCrashProcess(context: Context) =
|
||||
ProcessNameCompat.getProcessName(context)
|
||||
.endsWith(context.getString(R.string.co_electriccoin_zcash_crash_process_name_suffix))
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import co.electriccoin.zcash.spackle.process.AbstractProcessNameContentProvider
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package co.electriccoin.zcash.crash.android.internal
|
||||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
|
@ -0,0 +1,48 @@
|
|||
package co.electriccoin.zcash.crash.android.internal.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import co.electriccoin.zcash.crash.ReportableException
|
||||
import co.electriccoin.zcash.crash.android.internal.CrashReporter
|
||||
import co.electriccoin.zcash.spackle.LazyWithArgument
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Registers an exception handler to write exceptions to disk.
|
||||
*/
|
||||
internal class LocalCrashReporter(private val applicationContext: Context) : CrashReporter {
|
||||
|
||||
private val crashReportingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
@AnyThread
|
||||
override fun reportCaughtException(exception: Throwable) {
|
||||
crashReportingScope.launch {
|
||||
AndroidExceptionReporter.reportException(
|
||||
applicationContext,
|
||||
ReportableException.new(applicationContext, exception, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable() {
|
||||
// Noop, because there's no privacy implication for locally stored data
|
||||
}
|
||||
|
||||
override fun disableAndDelete() {
|
||||
// Noop, because there's no privacy implication for locally stored data
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val lazyWithArgument = LazyWithArgument<Context, CrashReporter> {
|
||||
AndroidUncaughtExceptionHandler.register(it)
|
||||
LocalCrashReporter(it.applicationContext)
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): CrashReporter {
|
||||
return lazyWithArgument.getInstance(context)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="co_electriccoin_zcash_crash_is_use_secondary_process">true</bool>
|
||||
<!-- Expected to be overridden by a resource overlay in the app module, generated
|
||||
based on the presence of the Firebase API keys -->
|
||||
<bool name="co_electriccoin_zcash_crash_is_firebase_enabled">false</bool>
|
||||
</resources>
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="co_electriccoin_zcash_crash_process_name_suffix">:crash</string>
|
||||
</resources>
|
12
docs/CI.md
12
docs/CI.md
|
@ -3,7 +3,7 @@ Continuous integration is set up with GitHub Actions. The workflows are defined
|
|||
|
||||
Workflows exist for:
|
||||
* Pull request - On pull request, static analysis and testing is performed.
|
||||
* Deploy - Work in progress. On merge to the main branch, a release build is automatically deployed. Concurrency limits are in place, to ensure that only one release deployment can happen at a time.
|
||||
* Deploy - On merge to the main branch, a release build is automatically deployed. Concurrency limits are in place, to ensure that only one release deployment can happen at a time.
|
||||
|
||||
## Setup
|
||||
When forking this repository, some variables/secrets need to be defined to set up new continuous integration builds.
|
||||
|
@ -19,6 +19,8 @@ To enhance security, [OpenID Connect](https://docs.github.com/en/actions/deploym
|
|||
* `EMULATOR_WTF_API_KEY` - API key for [Emulator.wtf](https://emulator.wtf)
|
||||
* `FIREBASE_TEST_LAB_SERVICE_ACCOUNT` - Email address of Firebase Test Lab service account.
|
||||
* `FIREBASE_TEST_LAB_WORKLOAD_IDENTITY_PROVIDER` - Workload identity provider to generate temporary service account key.
|
||||
* `FIREBASE_DEBUG_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
|
||||
* `FIREBASE_RELEASE_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
|
||||
|
||||
The Pull Request workflow supports testing of the app and libraries with both Emulator.wtf and Firebase Test Lab. By default, Emulator.wtf is used for library instrumentation tests, while Firebase Test Lab is used for a robo test.
|
||||
|
||||
|
@ -28,7 +30,9 @@ To configure Firebase Test Lab, you'll need to enable the necessary Google Cloud
|
|||
|
||||
Note that pull requests will create a "release" build with a temporary fake signing key. This simplifies configuration of CI for forks who simply want to run tests and not do release deployments. The limitations of this approach are:
|
||||
- These builds cannot be used for testing of app upgrade compatibility (since signing key is different each time)
|
||||
- Firebase, Google Play Services, and Google Maps won't work since they use the signing key to restrict API access. The app does not currently use these services, so this is a non-issue for now.
|
||||
- Firebase, Google Play Services, and Google Maps won't work since they use the signing key to restrict API access. The app does not currently use any services with signature checks but this could become an issue in the future.
|
||||
|
||||
Note that `FIREBASE_DEBUG_JSON_BASE64` and `FIREBASE_RELEASE_JSON_BASE64` are not truly considered secret, as they contain API keys that are embedded in the application. However we are not including them in the repository to reduce accidental pollution of our crash report data from repository forks.
|
||||
|
||||
### Release deployment
|
||||
* Secrets
|
||||
|
@ -39,6 +43,8 @@ Note that pull requests will create a "release" build with a temporary fake sign
|
|||
* `UPLOAD_KEYSTORE_PASSWORD` — Password for upload keystore.
|
||||
* `UPLOAD_KEY_ALIAS` — Name of key inside upload keystore.
|
||||
* `UPLOAD_KEY_ALIAS_PASSWORD` — Password for key alias.
|
||||
* `FIREBASE_DEBUG_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
|
||||
* `FIREBASE_RELEASE_JSON_BASE64` - Base64 encoded google-services.json file for enabling Firebase services such as Crashlytics.
|
||||
|
||||
To obtain the values for the Google Play deployment, you'll need to
|
||||
|
||||
|
@ -48,3 +54,5 @@ To obtain the values for the Google Play deployment, you'll need to
|
|||
Note that security of release deployments is enhanced via two mechanisms:
|
||||
- CI signs the app with the upload keystore and not the final release keystore. If the upload keystore is ever leaked, it can be rotated without impacting end user security.
|
||||
- Deployment to Google Play can only be made to testing tracks. Release to production requires manual human login under a different account with greater permissions.
|
||||
|
||||
Note that `FIREBASE_DEBUG_JSON_BASE64` and `FIREBASE_RELEASE_JSON_BASE64` are not truly considered secret, as they contain API keys that are embedded in the application. However we are not including them in the repository to reduce accidental pollution of our crash report data from repository forks.
|
|
@ -9,5 +9,14 @@ The application will log crashes to external storage and can also include some i
|
|||
6. Confirm that a new exception file exists in this directory
|
||||
7. Repeat this with the "Throw Uncaught Exception" under the debug menu
|
||||
|
||||
# Crashes reported to Crashlytics
|
||||
1. Compile a debug build of the app with Firebase API keys
|
||||
1. Download Firebase JSON configuration files from https://console.firebase.google.com and place them in app/src/debug and app/src/release
|
||||
1. OR download an APK built by GitHub Actions which has the API keys set up
|
||||
1. Get past the onboarding to reach the Home screen
|
||||
1. Under the debug menu, choose Report Caught Exception
|
||||
1. Log onto the Firebase project and confirm the exception is reported
|
||||
1. Repeat this with the "Throw Uncaught Exception" under the debug menu
|
||||
|
||||
# Crashes Reported in Contact Support
|
||||
1. See the Contact Support test cases
|
|
@ -105,8 +105,10 @@ ANDROID_NDK_VERSION=23.0.7599858
|
|||
ANDROID_GRADLE_PLUGIN_VERSION=7.4.0
|
||||
DETEKT_VERSION=1.22.0
|
||||
EMULATOR_WTF_GRADLE_PLUGIN_VERSION=0.0.15
|
||||
FIREBASE_CRASHLYTICS_BUILD_TOOLS_VERSION=2.9.2
|
||||
FLANK_VERSION=23.01.0
|
||||
FULLADLE_VERSION=0.17.4
|
||||
GOOGLE_PLAY_SERVICES_GRADLE_PLUGIN_VERSION=4.3.15
|
||||
GRADLE_VERSIONS_PLUGIN_VERSION=0.42.0
|
||||
JGIT_VERSION=6.1.0.202203080745-r
|
||||
KTLINT_VERSION=0.48.0
|
||||
|
@ -139,6 +141,7 @@ ANDROIDX_TEST_SERVICE_VERSION=1.4.2
|
|||
ANDROIDX_UI_AUTOMATOR_VERSION=2.2.0-alpha1
|
||||
ANDROIDX_WORK_MANAGER_VERSION=2.7.1
|
||||
CORE_LIBRARY_DESUGARING_VERSION=2.0.0
|
||||
FIREBASE_BOM_VERSION_MATCHER=31.2.0
|
||||
JACOCO_VERSION=0.8.8
|
||||
KOTLIN_VERSION=1.8.0
|
||||
KOTLINX_COROUTINES_VERSION=1.6.4
|
||||
|
|
|
@ -10,14 +10,9 @@ android {
|
|||
namespace = "cash.z.ecc.sdk.ext.ui"
|
||||
testNamespace = "cash.z.ecc.sdk.ext.ui.test"
|
||||
resourcePrefix = "co_electriccoin_zcash_"
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
implementation(projects.sdkExtLib)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
|
|
@ -214,6 +214,10 @@ dependencyResolutionManagement {
|
|||
library("androidx-viewmodel-compose", "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
||||
library("androidx-workmanager", "androidx.work:work-runtime-ktx:$androidxWorkManagerVersion")
|
||||
library("desugaring", "com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
||||
library("firebase-bom", "com.google.firebase:firebase-bom:${extra["FIREBASE_BOM_VERSION_MATCHER"]}")
|
||||
library("firebase-installations", "com.google.firebase", "firebase-installations").withoutVersion()
|
||||
library("firebase-crashlytics", "com.google.firebase", "firebase-crashlytics-ktx").withoutVersion()
|
||||
library("firebase-crashlytics-ndk", "com.google.firebase", "firebase-crashlytics-ndk").withoutVersion()
|
||||
library("kotlin-stdlib", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||
library("kotlin-reflect", "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
||||
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion")
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.content.pm.ProviderInfo
|
|||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import androidx.annotation.RequiresApi
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
|
||||
|
@ -22,7 +23,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
|
|||
override fun attachInfo(context: Context, info: ProviderInfo) {
|
||||
super.attachInfo(context, info)
|
||||
|
||||
val processName: String = if (AndroidApiVersion.isAtLeastP) {
|
||||
val processName: String = if (AndroidApiVersion.isAtLeastT) {
|
||||
getProcessNameTPlus()
|
||||
} else if (AndroidApiVersion.isAtLeastP) {
|
||||
getProcessNamePPlus()
|
||||
} else {
|
||||
getProcessNameLegacy(context, info)
|
||||
|
@ -31,6 +34,9 @@ open class AbstractProcessNameContentProvider : ContentProvider() {
|
|||
ProcessNameCompat.setProcessName(processName)
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
|
||||
private fun getProcessNameTPlus() = Process.myProcessName()
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.P)
|
||||
private fun getProcessNamePPlus(): String = Application.getProcessName()
|
||||
|
||||
|
|
|
@ -56,13 +56,18 @@ object ProcessNameCompat {
|
|||
* due to some race conditions in Android.
|
||||
*/
|
||||
private fun searchForProcessName(context: Context): String? {
|
||||
return if (AndroidApiVersion.isAtLeastP) {
|
||||
return if (AndroidApiVersion.isAtLeastT) {
|
||||
getProcessNameTPlus()
|
||||
} else if (AndroidApiVersion.isAtLeastP) {
|
||||
getProcessNamePPlus()
|
||||
} else {
|
||||
searchForProcessNameLegacy(context)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
|
||||
private fun getProcessNameTPlus() = Process.myProcessName()
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.P)
|
||||
private fun getProcessNamePPlus() = Application.getProcessName()
|
||||
|
||||
|
|
|
@ -33,7 +33,6 @@ fun Body(
|
|||
modifier: Modifier = Modifier,
|
||||
textAlign: TextAlign = TextAlign.Start,
|
||||
color: Color = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
|
|
|
@ -13,10 +13,6 @@ android {
|
|||
testInstrumentationRunner = "co.electriccoin.zcash.test.ZcashUiTestRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
@ -52,8 +48,6 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
|
|
@ -65,6 +65,20 @@ class SettingsViewTest : UiTestPrerequisites() {
|
|||
assertEquals(1, testSetup.getRescanCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun toggle_analytics() = runTest {
|
||||
val testSetup = TestSetup(composeTestRule)
|
||||
|
||||
assertEquals(0, testSetup.getBackupCount())
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.settings_enable_analytics)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(1, testSetup.getAnalyticsToggleCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
@Ignore("Wipe has been disabled in Settings and is now a debug-only option")
|
||||
|
@ -86,6 +100,7 @@ class SettingsViewTest : UiTestPrerequisites() {
|
|||
private val onBackupCount = AtomicInteger(0)
|
||||
private val onRescanCount = AtomicInteger(0)
|
||||
private val onWipeCount = AtomicInteger(0)
|
||||
private val onAnalyticsChangedCount = AtomicInteger(0)
|
||||
|
||||
fun getOnBackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
|
@ -107,10 +122,16 @@ class SettingsViewTest : UiTestPrerequisites() {
|
|||
return onWipeCount.get()
|
||||
}
|
||||
|
||||
fun getAnalyticsToggleCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onAnalyticsChangedCount.get()
|
||||
}
|
||||
|
||||
init {
|
||||
composeTestRule.setContent {
|
||||
ZcashTheme {
|
||||
Settings(
|
||||
isAnalyticsEnabled = true,
|
||||
onBack = {
|
||||
onBackCount.incrementAndGet()
|
||||
},
|
||||
|
@ -122,6 +143,9 @@ class SettingsViewTest : UiTestPrerequisites() {
|
|||
},
|
||||
onWipeWallet = {
|
||||
onWipeCount.incrementAndGet()
|
||||
},
|
||||
onAnalyticsSettingsChanged = {
|
||||
onAnalyticsChangedCount.incrementAndGet()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,9 @@ object StandardPreferenceKeys {
|
|||
*/
|
||||
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false)
|
||||
|
||||
// Default to true until https://github.com/zcash/secant-android-wallet/issues/304
|
||||
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(Key("is_analytics_enabled"), true)
|
||||
|
||||
/**
|
||||
* The fiat currency that the user prefers.
|
||||
*/
|
||||
|
|
|
@ -44,7 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
|
||||
import cash.z.ecc.sdk.model.PercentDecimal
|
||||
import co.electriccoin.zcash.crash.android.CrashReporter
|
||||
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
||||
import co.electriccoin.zcash.ui.design.component.Body
|
||||
|
@ -153,7 +153,7 @@ private fun DebugMenu(resetSdk: () -> Unit, wipeEntireWallet: () -> Unit) {
|
|||
onClick = {
|
||||
// Eventually this shouldn't rely on the Android implementation, but rather an expect/actual
|
||||
// should be used at the crash API level.
|
||||
CrashReporter.reportCaughtException(RuntimeException("Manually caught exception from debug menu"))
|
||||
GlobalCrashReporter.reportCaughtException(RuntimeException("Manually caught exception from debug menu"))
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ import co.electriccoin.zcash.ui.MainActivity
|
|||
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||
import co.electriccoin.zcash.ui.screen.settings.view.Settings
|
||||
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
internal fun MainActivity.WrapSettings(
|
||||
|
@ -30,13 +31,16 @@ private fun WrapSettings(
|
|||
goWalletBackup: () -> Unit
|
||||
) {
|
||||
val walletViewModel by activity.viewModels<WalletViewModel>()
|
||||
val settingsViewModel by activity.viewModels<SettingsViewModel>()
|
||||
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
val isAnalyticsEnabled = settingsViewModel.isAnalyticsEnabled.collectAsStateWithLifecycle().value
|
||||
|
||||
if (null == synchronizer) {
|
||||
if (null == synchronizer || null == isAnalyticsEnabled) {
|
||||
// Display loading indicator
|
||||
} else {
|
||||
Settings(
|
||||
isAnalyticsEnabled,
|
||||
onBack = goBack,
|
||||
onBackupWallet = goWalletBackup,
|
||||
onRescanWallet = {
|
||||
|
@ -48,6 +52,9 @@ private fun WrapSettings(
|
|||
val onboardingViewModel by activity.viewModels<OnboardingViewModel>()
|
||||
onboardingViewModel.onboardingState.goToBeginning()
|
||||
onboardingViewModel.isImporting.value = false
|
||||
},
|
||||
onAnalyticsSettingsChanged = {
|
||||
settingsViewModel.setAnalyticsEnabled(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
package co.electriccoin.zcash.ui.screen.settings.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
|
@ -9,13 +14,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
||||
import co.electriccoin.zcash.ui.design.component.Body
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
||||
import co.electriccoin.zcash.ui.design.component.TertiaryButton
|
||||
|
@ -27,10 +39,12 @@ fun PreviewSettings() {
|
|||
ZcashTheme(darkTheme = true) {
|
||||
GradientSurface {
|
||||
Settings(
|
||||
isAnalyticsEnabled = true,
|
||||
onBack = {},
|
||||
onBackupWallet = {},
|
||||
onWipeWallet = {},
|
||||
onRescanWallet = {}
|
||||
onRescanWallet = {},
|
||||
onAnalyticsSettingsChanged = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -38,20 +52,25 @@ fun PreviewSettings() {
|
|||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
fun Settings(
|
||||
isAnalyticsEnabled: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onBackupWallet: () -> Unit,
|
||||
onWipeWallet: () -> Unit,
|
||||
onRescanWallet: () -> Unit
|
||||
onRescanWallet: () -> Unit,
|
||||
onAnalyticsSettingsChanged: (Boolean) -> Unit
|
||||
) {
|
||||
Scaffold(topBar = {
|
||||
SettingsTopAppBar(onBack = onBack)
|
||||
}) { paddingValues ->
|
||||
SettingsMainContent(
|
||||
paddingValues,
|
||||
isAnalyticsEnabled,
|
||||
onBackupWallet = onBackupWallet,
|
||||
onWipeWallet = onWipeWallet,
|
||||
onRescanWallet = onRescanWallet
|
||||
onRescanWallet = onRescanWallet,
|
||||
onAnalyticsSettingsChanged = onAnalyticsSettingsChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -75,11 +94,14 @@ private fun SettingsTopAppBar(onBack: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
private fun SettingsMainContent(
|
||||
paddingValues: PaddingValues,
|
||||
isAnalyticsEnabled: Boolean,
|
||||
onBackupWallet: () -> Unit,
|
||||
@Suppress("UNUSED_PARAMETER") onWipeWallet: () -> Unit,
|
||||
onRescanWallet: () -> Unit
|
||||
onRescanWallet: () -> Unit,
|
||||
onAnalyticsSettingsChanged: (Boolean) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
@ -89,5 +111,36 @@ private fun SettingsMainContent(
|
|||
// We have decided to not include this in settings; see overflow debug menu instead
|
||||
// DangerousButton(onClick = onWipeWallet, text = stringResource(id = R.string.settings_wipe))
|
||||
TertiaryButton(onClick = onRescanWallet, text = stringResource(id = R.string.settings_rescan))
|
||||
SwitchWithLabel(
|
||||
label = stringResource(id = R.string.settings_enable_analytics),
|
||||
state = isAnalyticsEnabled,
|
||||
onStateChange = { onAnalyticsSettingsChanged(!isAnalyticsEnabled) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchWithLabel(label: String, state: Boolean, onStateChange: (Boolean) -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null, // disable ripple
|
||||
role = Role.Switch,
|
||||
onClick = { onStateChange(!state) }
|
||||
)
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Body(text = label)
|
||||
Spacer(modifier = Modifier.fillMaxWidth(MINIMAL_WEIGHT))
|
||||
Switch(
|
||||
checked = state,
|
||||
onCheckedChange = {
|
||||
onStateChange(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package co.electriccoin.zcash.ui.screen.settings.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
val isAnalyticsEnabled: StateFlow<Boolean?> = flow<Boolean?> {
|
||||
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
||||
emitAll(StandardPreferenceKeys.IS_ANALYTICS_ENABLED.observe(preferenceProvider))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null)
|
||||
|
||||
fun setAnalyticsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val prefs = StandardPreferenceSingleton.getInstance(getApplication())
|
||||
mutex.withLock {
|
||||
// Note that the Application object observes this and performs the actual side effects
|
||||
StandardPreferenceKeys.IS_ANALYTICS_ENABLED.putValue(prefs, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,5 +6,6 @@
|
|||
<string name="settings_wipe">Wipe Wallet Data</string>
|
||||
|
||||
<string name="settings_rescan">Rescan Blockchain</string>
|
||||
<string name="settings_enable_analytics">Report crashes</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
private const val DEFAULT_TIMEOUT_MILLISECONDS = 5_000L
|
||||
private const val DEFAULT_TIMEOUT_MILLISECONDS = 10_000L
|
||||
|
||||
/*
|
||||
* This screenshot implementation does not change the system-wide configuration, but rather
|
||||
|
|
Loading…
Reference in New Issue