parent
a5d38c6bf9
commit
6e85764f74
@ -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() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
@ -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 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> |
||||
</resources> |
||||
<!-- 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> |