diff --git a/app/build.gradle b/app/build.gradle index 0529bbbf97..5f85a7c7de 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.application' apply plugin: 'com.bugsnag.android.gradle' +apply plugin: 'kotlin-android' def getVersionCode = { -> return 1591 } def getReleaseName = { -> return "\"Pteropelyx\"" } @@ -406,6 +407,7 @@ dependencies { implementation("com.bugsnag:bugsnag-android:$bugsnag_version") { //exclude group: "com.bugsnag", module: "bugsnag-plugin-android-anr" exclude group: "com.bugsnag", module: "bugsnag-plugin-android-ndk" + exclude group: "com.bugsnag", module: "bugsnag-android-core" } // https://github.com/mangstadt/biweekly diff --git a/app/src/main/java/com/bugsnag/android/ActivityBreadcrumbCollector.kt b/app/src/main/java/com/bugsnag/android/ActivityBreadcrumbCollector.kt new file mode 100644 index 0000000000..27f9d9b47b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ActivityBreadcrumbCollector.kt @@ -0,0 +1,49 @@ +package com.bugsnag.android + +import android.app.Activity +import android.app.Application +import android.os.Bundle + +internal class ActivityBreadcrumbCollector( + private val cb: (message: String, method: Map) -> Unit +) : Application.ActivityLifecycleCallbacks { + + var prevState: String? = null + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = + leaveBreadcrumb(getActivityName(activity), "onCreate()", savedInstanceState != null) + + override fun onActivityStarted(activity: Activity) = + leaveBreadcrumb(getActivityName(activity), "onStart()") + + override fun onActivityResumed(activity: Activity) = + leaveBreadcrumb(getActivityName(activity), "onResume()") + + override fun onActivityPaused(activity: Activity) = + leaveBreadcrumb(getActivityName(activity), "onPause()") + + override fun onActivityStopped(activity: Activity) = + leaveBreadcrumb(getActivityName(activity), "onStop()") + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = + leaveBreadcrumb(getActivityName(activity), "onSaveInstanceState()") + + override fun onActivityDestroyed(activity: Activity) = + leaveBreadcrumb(getActivityName(activity), "onDestroy()") + + private fun getActivityName(activity: Activity) = activity.javaClass.simpleName + + private fun leaveBreadcrumb(activityName: String, lifecycleCallback: String, hasBundle: Boolean? = null) { + val metadata = mutableMapOf() + if (hasBundle != null) { + metadata["hasBundle"] = hasBundle + } + val previousVal = prevState + + if (previousVal != null) { + metadata["previous"] = previousVal + } + cb("$activityName#$lifecycleCallback", metadata) + prevState = lifecycleCallback + } +} diff --git a/app/src/main/java/com/bugsnag/android/App.kt b/app/src/main/java/com/bugsnag/android/App.kt new file mode 100644 index 0000000000..bbdf02b4ea --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/App.kt @@ -0,0 +1,86 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Stateless information set by the notifier about your app can be found on this class. These values + * can be accessed and amended if necessary. + */ +open class App internal constructor( + /** + * The architecture of the running application binary + */ + var binaryArch: String?, + + /** + * The package name of the application + */ + var id: String?, + + /** + * The release stage set in [Configuration.releaseStage] + */ + var releaseStage: String?, + + /** + * The version of the application set in [Configuration.version] + */ + var version: String?, + + /** + The revision ID from the manifest (React Native apps only) + */ + var codeBundleId: String?, + + /** + * The unique identifier for the build of the application set in [Configuration.buildUuid] + */ + var buildUuid: String?, + + /** + * The application type set in [Configuration#version] + */ + var type: String?, + + /** + * The version code of the application set in [Configuration.versionCode] + */ + var versionCode: Number? +) : JsonStream.Streamable { + + internal constructor( + config: ImmutableConfig, + binaryArch: String?, + id: String?, + releaseStage: String?, + version: String?, + codeBundleId: String? + ) : this( + binaryArch, + id, + releaseStage, + version, + codeBundleId, + config.buildUuid, + config.appType, + config.versionCode + ) + + internal open fun serialiseFields(writer: JsonStream) { + writer.name("binaryArch").value(binaryArch) + writer.name("buildUUID").value(buildUuid) + writer.name("codeBundleId").value(codeBundleId) + writer.name("id").value(id) + writer.name("releaseStage").value(releaseStage) + writer.name("type").value(type) + writer.name("version").value(version) + writer.name("versionCode").value(versionCode) + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + serialiseFields(writer) + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/AppDataCollector.kt b/app/src/main/java/com/bugsnag/android/AppDataCollector.kt new file mode 100644 index 0000000000..e270d2f96a --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/AppDataCollector.kt @@ -0,0 +1,133 @@ +package com.bugsnag.android + +import android.app.ActivityManager +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.SystemClock +import java.util.HashMap + +/** + * Collects various data on the application state + */ +internal class AppDataCollector( + appContext: Context, + private val packageManager: PackageManager?, + private val config: ImmutableConfig, + private val sessionTracker: SessionTracker, + private val activityManager: ActivityManager?, + private val launchCrashTracker: LaunchCrashTracker, + private val logger: Logger +) { + var codeBundleId: String? = null + + private val packageName: String = appContext.packageName + private var packageInfo = packageManager?.getPackageInfo(packageName, 0) + private var appInfo: ApplicationInfo? = packageManager?.getApplicationInfo(packageName, 0) + + private var binaryArch: String? = null + private val appName = getAppName() + private val releaseStage = config.releaseStage + private val versionName = config.appVersion ?: packageInfo?.versionName + + fun generateApp(): App = App(config, binaryArch, packageName, releaseStage, versionName, codeBundleId) + + fun generateAppWithState(): AppWithState = AppWithState( + config, binaryArch, packageName, releaseStage, versionName, codeBundleId, + getDurationMs(), calculateDurationInForeground(), sessionTracker.isInForeground, + launchCrashTracker.isLaunching() + ) + + fun getAppDataMetadata(): MutableMap { + val map = HashMap() + map["name"] = appName + map["activeScreen"] = getActiveScreenClass() + map["memoryUsage"] = getMemoryUsage() + map["lowMemory"] = isLowMemory() + + isBackgroundWorkRestricted()?.let { + map["backgroundWorkRestricted"] = it + } + return map + } + + fun getActiveScreenClass(): String? = sessionTracker.contextActivity + + /** + * Get the actual memory used by the VM (which may not be the total used + * by the app in the case of NDK usage). + */ + private fun getMemoryUsage(): Long { + val runtime = Runtime.getRuntime() + return runtime.totalMemory() - runtime.freeMemory() + } + + /** + * Checks whether the user has restricted the amount of work this app can do in the background. + * https://developer.android.com/reference/android/app/ActivityManager#isBackgroundRestricted() + */ + private fun isBackgroundWorkRestricted(): Boolean? { + return if (activityManager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + null + } else if (activityManager.isBackgroundRestricted) { + true // only return non-null value if true to avoid noise in error reports + } else { + null + } + } + + /** + * Check if the device is currently running low on memory. + */ + private fun isLowMemory(): Boolean? { + try { + if (activityManager != null) { + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + return memInfo.lowMemory + } + } catch (exception: Exception) { + logger.w("Could not check lowMemory status") + } + return null + } + + fun setBinaryArch(binaryArch: String) { + this.binaryArch = binaryArch + } + + /** + * Calculates the duration the app has been in the foreground + * + * @return the duration in ms + */ + internal fun calculateDurationInForeground(): Long? { + val nowMs = System.currentTimeMillis() + return sessionTracker.getDurationInForegroundMs(nowMs) + } + + /** + * The name of the running Android app, from android:label in + * AndroidManifest.xml + */ + private fun getAppName(): String? { + val copy = appInfo + return when { + packageManager != null && copy != null -> { + packageManager.getApplicationLabel(copy).toString() + } + else -> null + } + } + + companion object { + internal val startTimeMs = SystemClock.elapsedRealtime() + + /** + * Get the time in milliseconds since Bugsnag was initialized, which is a + * good approximation for how long the app has been running. + */ + fun getDurationMs(): Long = SystemClock.elapsedRealtime() - startTimeMs + } +} diff --git a/app/src/main/java/com/bugsnag/android/AppWithState.kt b/app/src/main/java/com/bugsnag/android/AppWithState.kt new file mode 100644 index 0000000000..173ce4e192 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/AppWithState.kt @@ -0,0 +1,72 @@ +package com.bugsnag.android + +/** + * Stateful information set by the notifier about your app can be found on this class. These values + * can be accessed and amended if necessary. + */ +class AppWithState( + binaryArch: String?, + id: String?, + releaseStage: String?, + version: String?, + codeBundleId: String?, + buildUuid: String?, + type: String?, + versionCode: Number?, + + /** + * The number of milliseconds the application was running before the event occurred + */ + var duration: Number?, + + /** + * The number of milliseconds the application was running in the foreground before the + * event occurred + */ + var durationInForeground: Number?, + + /** + * Whether the application was in the foreground when the event occurred + */ + var inForeground: Boolean?, + + /** + * Whether the application was launching when the event occurred + */ + var isLaunching: Boolean? +) : App(binaryArch, id, releaseStage, version, codeBundleId, buildUuid, type, versionCode) { + + internal constructor( + config: ImmutableConfig, + binaryArch: String?, + id: String?, + releaseStage: String?, + version: String?, + codeBundleId: String?, + duration: Number?, + durationInForeground: Number?, + inForeground: Boolean?, + isLaunching: Boolean? + ) : this( + binaryArch, + id, + releaseStage, + version, + codeBundleId, + config.buildUuid, + config.appType, + config.versionCode, + duration, + durationInForeground, + inForeground, + isLaunching + ) + + override fun serialiseFields(writer: JsonStream) { + super.serialiseFields(writer) + writer.name("duration").value(duration) + writer.name("durationInForeground").value(durationInForeground) + writer.name("inForeground").value(inForeground) + writer.name("isLaunching").value(isLaunching) + } +} diff --git a/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt new file mode 100644 index 0000000000..01bb3e17d3 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt @@ -0,0 +1,174 @@ +package com.bugsnag.android + +import androidx.annotation.VisibleForTesting +import java.util.concurrent.BlockingQueue +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadFactory +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +/** + * The type of task which is being submitted. This determines which execution queue + * the task will be added to. + */ +internal enum class TaskType { + + /** + * A task that sends an error request. Any filesystem operations + * that persist/delete errors must be submitted using this type. + */ + ERROR_REQUEST, + + /** + * A task that sends a session request. Any filesystem operations + * that persist/delete sessions must be submitted using this type. + */ + SESSION_REQUEST, + + /** + * A task that performs I/O, such as reading a file on disk. This should NOT include operations + * related to error/session storage - use [ERROR_REQUEST] or [SESSION_REQUEST] instead. + */ + IO, + + /** + * A task that sends an internal error report to Bugsnag. + */ + INTERNAL_REPORT, + + /** + * Any other task that needs to run in the background. These will typically be + * short-lived operations that take <100ms, such as registering a + * [android.content.BroadcastReceiver]. + */ + DEFAULT +} + +private const val SHUTDOWN_WAIT_MS = 1500L + +// these values have been loosely adapted from android.os.AsyncTask over the years. +private const val THREAD_POOL_SIZE = 1 +private const val KEEP_ALIVE_SECS = 30L +private const val TASK_QUEUE_SIZE = 128 + +internal fun createExecutor(name: String, keepAlive: Boolean): ThreadPoolExecutor { + val queue: BlockingQueue = LinkedBlockingQueue(TASK_QUEUE_SIZE) + val threadFactory = ThreadFactory { Thread(it, name) } + + // certain executors (error/session/io) should always keep their threads alive, but others + // are less important so are allowed a pool size of 0 that expands on demand. + val coreSize = when { + keepAlive -> THREAD_POOL_SIZE + else -> 0 + } + return ThreadPoolExecutor( + coreSize, + THREAD_POOL_SIZE, + KEEP_ALIVE_SECS, + TimeUnit.SECONDS, + queue, + threadFactory + ) +} + +/** + * Provides a service for submitting lengthy tasks to run on background threads. + * + * A [TaskType] must be submitted with each task, which routes it to the appropriate executor. + * Setting the correct [TaskType] is critical as it can be used to enforce thread confinement. + * It also avoids short-running operations being held up by long-running operations submitted + * to the same executor. + */ +internal class BackgroundTaskService( + // these executors must remain single-threaded - the SDK makes assumptions + // about synchronization based on this. + @VisibleForTesting + internal val errorExecutor: ThreadPoolExecutor = createExecutor( + "Bugsnag Error thread", + true + ), + + @VisibleForTesting + internal val sessionExecutor: ThreadPoolExecutor = createExecutor( + "Bugsnag Session thread", + true + ), + + @VisibleForTesting + internal val ioExecutor: ThreadPoolExecutor = createExecutor( + "Bugsnag IO thread", + true + ), + + @VisibleForTesting + internal val internalReportExecutor: ThreadPoolExecutor = createExecutor( + "Bugsnag Internal Report thread", + false + ), + + @VisibleForTesting + internal val defaultExecutor: ThreadPoolExecutor = createExecutor( + "Bugsnag Default thread", + false + ) +) { + + /** + * Submits a task for execution on a single-threaded executor. It is guaranteed that tasks + * with the same [TaskType] are executed in the order of submission. + * + * The caller is responsible for catching and handling + * [java.util.concurrent.RejectedExecutionException] if the executor is saturated. + * + * On process termination the service will attempt to wait for previously submitted jobs + * with the task type [TaskType.ERROR_REQUEST], [TaskType.SESSION_REQUEST] and [TaskType.IO]. + * This is a best-effort attempt - no guarantee can be made that the operations will complete. + */ + @Throws(RejectedExecutionException::class) + fun submitTask(taskType: TaskType, runnable: Runnable): Future<*> { + return submitTask(taskType, Executors.callable(runnable)) + } + + /** + * @see [submitTask] + */ + @Throws(RejectedExecutionException::class) + fun submitTask(taskType: TaskType, callable: Callable): Future { + return when (taskType) { + TaskType.ERROR_REQUEST -> errorExecutor.submit(callable) + TaskType.SESSION_REQUEST -> sessionExecutor.submit(callable) + TaskType.IO -> ioExecutor.submit(callable) + TaskType.INTERNAL_REPORT -> internalReportExecutor.submit(callable) + TaskType.DEFAULT -> defaultExecutor.submit(callable) + } + } + + /** + * Notifies the background service that the process is about to terminate. This causes it to + * shutdown submission of tasks to executors, while allowing for in-flight tasks + * to be completed within a reasonable grace period. + */ + fun shutdown() { + // don't wait for existing tasks to complete for these executors, as they are + // less essential + internalReportExecutor.shutdownNow() + defaultExecutor.shutdownNow() + + // shutdown the error/session executors first, waiting for existing tasks to complete. + // If a request fails it may perform IO to persist the payload for delivery next launch, + // which would submit tasks to the IO executor - therefore it's critical to + // shutdown the IO executor last. + errorExecutor.shutdown() + sessionExecutor.shutdown() + errorExecutor.awaitTermination(SHUTDOWN_WAIT_MS, TimeUnit.MILLISECONDS) + sessionExecutor.awaitTermination(SHUTDOWN_WAIT_MS, TimeUnit.MILLISECONDS) + + // shutdown the IO executor last, waiting for any existing tasks to complete + ioExecutor.shutdown() + ioExecutor.awaitTermination(SHUTDOWN_WAIT_MS, TimeUnit.MILLISECONDS) + } +} diff --git a/app/src/main/java/com/bugsnag/android/BaseObservable.kt b/app/src/main/java/com/bugsnag/android/BaseObservable.kt new file mode 100644 index 0000000000..1f61b03d9b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/BaseObservable.kt @@ -0,0 +1,10 @@ +package com.bugsnag.android + +import java.util.Observable + +internal open class BaseObservable : Observable() { + fun notifyObservers(event: StateEvent) { + setChanged() + super.notifyObservers(event) + } +} diff --git a/app/src/main/java/com/bugsnag/android/Breadcrumb.java b/app/src/main/java/com/bugsnag/android/Breadcrumb.java new file mode 100644 index 0000000000..698223a6c0 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Breadcrumb.java @@ -0,0 +1,101 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.Date; +import java.util.Map; + +@SuppressWarnings("ConstantConditions") +public class Breadcrumb implements JsonStream.Streamable { + + private final BreadcrumbInternal impl; + private final Logger logger; + + Breadcrumb(@NonNull String message, @NonNull Logger logger) { + this.impl = new BreadcrumbInternal(message); + this.logger = logger; + } + + Breadcrumb(@NonNull String message, + @NonNull BreadcrumbType type, + @Nullable Map metadata, + @NonNull Date timestamp, + @NonNull Logger logger) { + this.impl = new BreadcrumbInternal(message, type, metadata, timestamp); + this.logger = logger; + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to breadcrumb." + property + ", ignoring"); + } + + /** + * Sets the description of the breadcrumb + */ + public void setMessage(@NonNull String message) { + if (message != null) { + impl.setMessage(message); + } else { + logNull("message"); + } + } + + /** + * Gets the description of the breadcrumb + */ + @NonNull + public String getMessage() { + return impl.getMessage(); + } + + /** + * Sets the type of breadcrumb left - one of those enabled in + * {@link Configuration#getEnabledBreadcrumbTypes()} + */ + public void setType(@NonNull BreadcrumbType type) { + if (type != null) { + impl.setType(type); + } else { + logNull("type"); + } + } + + /** + * Gets the type of breadcrumb left - one of those enabled in + * {@link Configuration#getEnabledBreadcrumbTypes()} + */ + @NonNull + public BreadcrumbType getType() { + return impl.getType(); + } + + /** + * Sets diagnostic data relating to the breadcrumb + */ + public void setMetadata(@Nullable Map metadata) { + impl.setMetadata(metadata); + } + + /** + * Gets diagnostic data relating to the breadcrumb + */ + @Nullable + public Map getMetadata() { + return impl.getMetadata(); + } + + /** + * The timestamp that the breadcrumb was left + */ + @NonNull + public Date getTimestamp() { + return impl.getTimestamp(); + } + + @Override + public void toStream(@NonNull JsonStream stream) throws IOException { + impl.toStream(stream); + } +} diff --git a/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt b/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt new file mode 100644 index 0000000000..147ac324ed --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt @@ -0,0 +1,35 @@ +package com.bugsnag.android + +import java.io.IOException +import java.util.Date + +/** + * In order to understand what happened in your application before each crash, it can be helpful + * to leave short log statements that we call breadcrumbs. Breadcrumbs are + * attached to a crash to help diagnose what events lead to the error. + */ +internal class BreadcrumbInternal internal constructor( + var message: String, + var type: BreadcrumbType, + var metadata: MutableMap?, + val timestamp: Date = Date() +) : JsonStream.Streamable { + + internal constructor(message: String) : this( + message, + BreadcrumbType.MANUAL, + mutableMapOf(), + Date() + ) + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("timestamp").value(DateUtils.toIso8601(timestamp)) + writer.name("name").value(message) + writer.name("type").value(type.toString()) + writer.name("metaData") + writer.value(metadata, true) + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/BreadcrumbState.kt b/app/src/main/java/com/bugsnag/android/BreadcrumbState.kt new file mode 100644 index 0000000000..a4d8593731 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/BreadcrumbState.kt @@ -0,0 +1,55 @@ +package com.bugsnag.android + +import java.io.IOException +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue + +internal class BreadcrumbState( + maxBreadcrumbs: Int, + val callbackState: CallbackState, + val logger: Logger +) : BaseObservable(), JsonStream.Streamable { + + val store: Queue = ConcurrentLinkedQueue() + + private val maxBreadcrumbs: Int + + init { + when { + maxBreadcrumbs > 0 -> this.maxBreadcrumbs = maxBreadcrumbs + else -> this.maxBreadcrumbs = 0 + } + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + pruneBreadcrumbs() + writer.beginArray() + store.forEach { it.toStream(writer) } + writer.endArray() + } + + fun add(breadcrumb: Breadcrumb) { + if (!callbackState.runOnBreadcrumbTasks(breadcrumb, logger)) { + return + } + + store.add(breadcrumb) + pruneBreadcrumbs() + notifyObservers( + StateEvent.AddBreadcrumb( + breadcrumb.message, + breadcrumb.type, + DateUtils.toIso8601(breadcrumb.timestamp), + breadcrumb.metadata ?: mutableMapOf() + ) + ) + } + + private fun pruneBreadcrumbs() { + // Remove oldest breadcrumbState until new max size reached + while (store.size > maxBreadcrumbs) { + store.poll() + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/BreadcrumbType.kt b/app/src/main/java/com/bugsnag/android/BreadcrumbType.kt new file mode 100644 index 0000000000..f1c05910a4 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/BreadcrumbType.kt @@ -0,0 +1,41 @@ +package com.bugsnag.android + +/** + * Recognized types of breadcrumbs + */ +enum class BreadcrumbType(private val type: String) { + /** + * An error was sent to Bugsnag (internal use only) + */ + ERROR("error"), + /** + * A log message + */ + LOG("log"), + /** + * A manual invocation of `leaveBreadcrumb` (default) + */ + MANUAL("manual"), + /** + * A navigation event, such as a window opening or closing + */ + NAVIGATION("navigation"), + /** + * A background process such as a database query + */ + PROCESS("process"), + /** + * A network request + */ + REQUEST("request"), + /** + * A change in application state, such as launch or memory warning + */ + STATE("state"), + /** + * A user action, such as tapping a button + */ + USER("user"); + + override fun toString() = type +} diff --git a/app/src/main/java/com/bugsnag/android/Bugsnag.java b/app/src/main/java/com/bugsnag/android/Bugsnag.java new file mode 100644 index 0000000000..74926ecd8b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Bugsnag.java @@ -0,0 +1,419 @@ +package com.bugsnag.android; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Static access to a Bugsnag Client, the easiest way to use Bugsnag in your Android app. + * For example: + *

+ * Bugsnag.start(this, "your-api-key"); + * Bugsnag.notify(new RuntimeException("something broke!")); + * + * @see Client + */ +@SuppressWarnings("checkstyle:JavadocTagContinuationIndentation") +public final class Bugsnag { + + private static final Object lock = new Object(); + + @SuppressLint("StaticFieldLeak") + static Client client; + + private Bugsnag() { + } + + /** + * Initialize the static Bugsnag client + * + * @param androidContext an Android context, usually this + */ + @NonNull + public static Client start(@NonNull Context androidContext) { + return start(androidContext, Configuration.load(androidContext)); + } + + /** + * Initialize the static Bugsnag client + * + * @param androidContext an Android context, usually this + * @param apiKey your Bugsnag API key from your Bugsnag dashboard + */ + @NonNull + public static Client start(@NonNull Context androidContext, @NonNull String apiKey) { + return start(androidContext, Configuration.load(androidContext, apiKey)); + } + + /** + * Initialize the static Bugsnag client + * + * @param androidContext an Android context, usually this + * @param config a configuration for the Client + */ + @NonNull + public static Client start(@NonNull Context androidContext, @NonNull Configuration config) { + synchronized (lock) { + if (client == null) { + client = new Client(androidContext, config); + } else { + logClientInitWarning(); + } + } + return client; + } + + private static void logClientInitWarning() { + getClient().logger.w("Multiple Bugsnag.start calls detected. Ignoring."); + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + @Nullable public static String getContext() { + return getClient().getContext(); + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + public static void setContext(@Nullable final String context) { + getClient().setContext(context); + } + + /** + * Sets the user associated with the event. + */ + public static void setUser(@Nullable final String id, + @Nullable final String email, + @Nullable final String name) { + getClient().setUser(id, email, name); + } + + /** + * Returns the currently set User information. + */ + @NonNull + public static User getUser() { + return getClient().getUser(); + } + + /** + * Add a "on error" callback, to execute code at the point where an error report is + * captured in Bugsnag. + * + * You can use this to add or modify information attached to an Event + * before it is sent to your dashboard. You can also return + * false from any callback to prevent delivery. "on error" + * callbacks do not run before reports generated in the event + * of immediate app termination from crashes in C/C++ code. + * + * For example: + * + * Bugsnag.addOnError(new OnErrorCallback() { + * public boolean run(Event event) { + * event.setSeverity(Severity.INFO); + * return true; + * } + * }) + * + * @param onError a callback to run before sending errors to Bugsnag + * @see OnErrorCallback + */ + public static void addOnError(@NonNull OnErrorCallback onError) { + getClient().addOnError(onError); + } + + /** + * Removes a previously added "on error" callback + * @param onError the callback to remove + */ + public static void removeOnError(@NonNull OnErrorCallback onError) { + getClient().removeOnError(onError); + } + + /** + * Add an "on breadcrumb" callback, to execute code before every + * breadcrumb captured by Bugsnag. + * + * You can use this to modify breadcrumbs before they are stored by Bugsnag. + * You can also return false from any callback to ignore a breadcrumb. + * + * For example: + * + * Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() { + * public boolean run(Breadcrumb breadcrumb) { + * return false; // ignore the breadcrumb + * } + * }) + * + * @param onBreadcrumb a callback to run before a breadcrumb is captured + * @see OnBreadcrumbCallback + */ + public static void addOnBreadcrumb(@NonNull final OnBreadcrumbCallback onBreadcrumb) { + getClient().addOnBreadcrumb(onBreadcrumb); + } + + /** + * Removes a previously added "on breadcrumb" callback + * @param onBreadcrumb the callback to remove + */ + public static void removeOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { + getClient().removeOnBreadcrumb(onBreadcrumb); + } + + /** + * Add an "on session" callback, to execute code before every + * session captured by Bugsnag. + * + * You can use this to modify sessions before they are stored by Bugsnag. + * You can also return false from any callback to ignore a session. + * + * For example: + * + * Bugsnag.onSession(new OnSessionCallback() { + * public boolean run(Session session) { + * return false; // ignore the session + * } + * }) + * + * @param onSession a callback to run before a session is captured + * @see OnSessionCallback + */ + public static void addOnSession(@NonNull OnSessionCallback onSession) { + getClient().addOnSession(onSession); + } + + /** + * Removes a previously added "on session" callback + * @param onSession the callback to remove + */ + public static void removeOnSession(@NonNull OnSessionCallback onSession) { + getClient().removeOnSession(onSession); + } + + /** + * Notify Bugsnag of a handled exception + * + * @param exception the exception to send to Bugsnag + */ + public static void notify(@NonNull final Throwable exception) { + getClient().notify(exception); + } + + /** + * Notify Bugsnag of a handled exception + * + * @param exception the exception to send to Bugsnag + * @param onError callback invoked on the generated error report for + * additional modification + */ + public static void notify(@NonNull final Throwable exception, + @Nullable final OnErrorCallback onError) { + getClient().notify(exception, onError); + } + + /** + * Adds a map of multiple metadata key-value pairs to the specified section. + */ + public static void addMetadata(@NonNull String section, @NonNull Map value) { + getClient().addMetadata(section, value); + } + + /** + * Adds the specified key and value in the specified section. The value can be of + * any primitive type or a collection such as a map, set or array. + */ + public static void addMetadata(@NonNull String section, @NonNull String key, + @Nullable Object value) { + getClient().addMetadata(section, key, value); + } + + /** + * Removes all the data from the specified section. + */ + public static void clearMetadata(@NonNull String section) { + getClient().clearMetadata(section); + } + + /** + * Removes data with the specified key from the specified section. + */ + public static void clearMetadata(@NonNull String section, @NonNull String key) { + getClient().clearMetadata(section, key); + } + + /** + * Returns a map of data in the specified section. + */ + @Nullable + public static Map getMetadata(@NonNull String section) { + return getClient().getMetadata(section); + } + + /** + * Returns the value of the specified key in the specified section. + */ + @Nullable + public static Object getMetadata(@NonNull String section, @NonNull String key) { + return getClient().getMetadata(section, key); + } + + /** + * Leave a "breadcrumb" log message, representing an action that occurred + * in your app, to aid with debugging. + * + * @param message the log message to leave + */ + public static void leaveBreadcrumb(@NonNull String message) { + getClient().leaveBreadcrumb(message); + } + + /** + * Leave a "breadcrumb" log message representing an action or event which + * occurred in your app, to aid with debugging + * @param message A short label + * @param metadata Additional diagnostic information about the app environment + * @param type A category for the breadcrumb + */ + public static void leaveBreadcrumb(@NonNull String message, + @NonNull Map metadata, + @NonNull BreadcrumbType type) { + getClient().leaveBreadcrumb(message, metadata, type); + } + + /** + * Starts tracking a new session. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to start a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This will start a new session even if there is already an existing + * session; you should call {@link #resumeSession()} if you only want to start a session + * when one doesn't already exist. + * + * @see #resumeSession() + * @see #pauseSession() + * @see Configuration#setAutoTrackSessions(boolean) + */ + public static void startSession() { + getClient().startSession(); + } + + /** + * Resumes a session which has previously been paused, or starts a new session if none exists. + * If a session has already been resumed or started and has not been paused, calling this + * method will have no effect. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * It's important to note that sessions are stored in memory for the lifetime of the + * application process and are not persisted on disk. Therefore calling this method on app + * startup would start a new session, rather than continuing any previous session. + *

+ * You should call this at the appropriate time in your application when you wish to resume + * a previously started session. Any subsequent errors which occur in your application will + * still be reported to Bugsnag but will not count towards your application's + * + * stability score. + * + * @see #startSession() + * @see #pauseSession() + * @see Configuration#setAutoTrackSessions(boolean) + * + * @return true if a previous session was resumed, false if a new session was started. + */ + public static boolean resumeSession() { + return getClient().resumeSession(); + } + + /** + * Pauses tracking of a session. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to pause a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This can be advantageous if, for example, you do not wish the + * stability score to include crashes in a background service. + * + * @see #startSession() + * @see #resumeSession() + * @see Configuration#setAutoTrackSessions(boolean) + */ + public static void pauseSession() { + getClient().pauseSession(); + } + + /** + * Returns the current buffer of breadcrumbs that will be sent with captured events. This + * ordered list represents the most recent breadcrumbs to be captured up to the limit + * set in {@link Configuration#getMaxBreadcrumbs()}. + * + * The returned collection is readonly and mutating the list will cause no effect on the + * Client's state. If you wish to alter the breadcrumbs collected by the Client then you should + * use {@link Configuration#setEnabledBreadcrumbTypes(Set)} and + * {@link Configuration#addOnBreadcrumb(OnBreadcrumbCallback)} instead. + * + * @return a list of collected breadcrumbs + */ + @NonNull + public static List getBreadcrumbs() { + return getClient().getBreadcrumbs(); + } + + /** + * Retrieves information about the last launch of the application, if it has been run before. + * + * For example, this allows checking whether the app crashed on its last launch, which could + * be used to perform conditional behaviour to recover from crashes, such as clearing the + * app data cache. + */ + @Nullable + public static LastRunInfo getLastRunInfo() { + return getClient().getLastRunInfo(); + } + + /** + * Informs Bugsnag that the application has finished launching. Once this has been called + * {@link AppWithState#isLaunching()} will always be false in any new error reports, + * and synchronous delivery will not be attempted on the next launch for any fatal crashes. + * + * By default this method will be called after Bugsnag is initialized when + * {@link Configuration#getLaunchDurationMillis()} has elapsed. Invoking this method manually + * has precedence over the value supplied via the launchDurationMillis configuration option. + */ + public static void markLaunchCompleted() { + getClient().markLaunchCompleted(); + } + + /** + * Get the current Bugsnag Client instance. + */ + @NonNull + public static Client getClient() { + if (client == null) { + throw new IllegalStateException("You must call Bugsnag.start before any" + + " other Bugsnag methods"); + } + + return client; + } +} diff --git a/app/src/main/java/com/bugsnag/android/CallbackAware.kt b/app/src/main/java/com/bugsnag/android/CallbackAware.kt new file mode 100644 index 0000000000..8dae0a395e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/CallbackAware.kt @@ -0,0 +1,10 @@ +package com.bugsnag.android + +internal interface CallbackAware { + fun addOnError(onError: OnErrorCallback) + fun removeOnError(onError: OnErrorCallback) + fun addOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) + fun removeOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) + fun addOnSession(onSession: OnSessionCallback) + fun removeOnSession(onSession: OnSessionCallback) +} diff --git a/app/src/main/java/com/bugsnag/android/CallbackState.kt b/app/src/main/java/com/bugsnag/android/CallbackState.kt new file mode 100644 index 0000000000..cb7ae14d3c --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/CallbackState.kt @@ -0,0 +1,79 @@ +package com.bugsnag.android + +import java.util.concurrent.ConcurrentLinkedQueue + +internal data class CallbackState( + val onErrorTasks: MutableCollection = ConcurrentLinkedQueue(), + val onBreadcrumbTasks: MutableCollection = ConcurrentLinkedQueue(), + val onSessionTasks: MutableCollection = ConcurrentLinkedQueue() +) : CallbackAware { + + override fun addOnError(onError: OnErrorCallback) { + onErrorTasks.add(onError) + } + + override fun removeOnError(onError: OnErrorCallback) { + onErrorTasks.remove(onError) + } + + override fun addOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) { + onBreadcrumbTasks.add(onBreadcrumb) + } + + override fun removeOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) { + onBreadcrumbTasks.remove(onBreadcrumb) + } + + override fun addOnSession(onSession: OnSessionCallback) { + onSessionTasks.add(onSession) + } + + override fun removeOnSession(onSession: OnSessionCallback) { + onSessionTasks.remove(onSession) + } + + fun runOnErrorTasks(event: Event, logger: Logger): Boolean { + onErrorTasks.forEach { + try { + if (!it.onError(event)) { + return false + } + } catch (ex: Throwable) { + logger.w("OnBreadcrumbCallback threw an Exception", ex) + } + } + return true + } + + fun runOnBreadcrumbTasks(breadcrumb: Breadcrumb, logger: Logger): Boolean { + onBreadcrumbTasks.forEach { + try { + if (!it.onBreadcrumb(breadcrumb)) { + return false + } + } catch (ex: Throwable) { + logger.w("OnBreadcrumbCallback threw an Exception", ex) + } + } + return true + } + + fun runOnSessionTasks(session: Session, logger: Logger): Boolean { + onSessionTasks.forEach { + try { + if (!it.onSession(session)) { + return false + } + } catch (ex: Throwable) { + logger.w("OnSessionCallback threw an Exception", ex) + } + } + return true + } + + fun copy() = this.copy( + onErrorTasks = onErrorTasks, + onBreadcrumbTasks = onBreadcrumbTasks, + onSessionTasks = onSessionTasks + ) +} diff --git a/app/src/main/java/com/bugsnag/android/Client.java b/app/src/main/java/com/bugsnag/android/Client.java new file mode 100644 index 0000000000..9723eb0f03 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Client.java @@ -0,0 +1,1005 @@ +package com.bugsnag.android; + +import static com.bugsnag.android.ImmutableConfigKt.sanitiseConfiguration; +import static com.bugsnag.android.SeverityReason.REASON_HANDLED_EXCEPTION; + +import android.app.ActivityManager; +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Resources; +import android.os.Environment; +import android.os.storage.StorageManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import kotlin.Unit; +import kotlin.jvm.functions.Function2; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Observer; +import java.util.Set; +import java.util.concurrent.RejectedExecutionException; + +/** + * A Bugsnag Client instance allows you to use Bugsnag in your Android app. + * Typically you'd instead use the static access provided in the Bugsnag class. + *

+ * Example usage: + *

+ * Client client = new Client(this, "your-api-key"); + * client.notify(new RuntimeException("something broke!")); + * + * @see Bugsnag + */ +@SuppressWarnings({"checkstyle:JavadocTagContinuationIndentation", "ConstantConditions"}) +public class Client implements MetadataAware, CallbackAware, UserAware { + + final ImmutableConfig immutableConfig; + + final MetadataState metadataState; + + private final ContextState contextState; + private final CallbackState callbackState; + private final UserState userState; + + final Context appContext; + + @NonNull + final DeviceDataCollector deviceDataCollector; + + @NonNull + final AppDataCollector appDataCollector; + + @NonNull + final BreadcrumbState breadcrumbState; + + @NonNull + protected final EventStore eventStore; + + private final SessionStore sessionStore; + + final SessionTracker sessionTracker; + + private final SystemBroadcastReceiver systemBroadcastReceiver; + private final ActivityBreadcrumbCollector activityBreadcrumbCollector; + private final SessionLifecycleCallback sessionLifecycleCallback; + + private final Connectivity connectivity; + private final StorageManager storageManager; + final Logger logger; + final DeliveryDelegate deliveryDelegate; + + final ClientObservable clientObservable = new ClientObservable(); + private PluginClient pluginClient; + + final Notifier notifier = new Notifier(); + + @Nullable + final LastRunInfo lastRunInfo; + final LastRunInfoStore lastRunInfoStore; + final LaunchCrashTracker launchCrashTracker; + final BackgroundTaskService bgTaskService = new BackgroundTaskService(); + + /** + * Initialize a Bugsnag client + * + * @param androidContext an Android context, usually this + */ + public Client(@NonNull Context androidContext) { + this(androidContext, Configuration.load(androidContext)); + } + + /** + * Initialize a Bugsnag client + * + * @param androidContext an Android context, usually this + * @param apiKey your Bugsnag API key from your Bugsnag dashboard + */ + public Client(@NonNull Context androidContext, @NonNull String apiKey) { + this(androidContext, Configuration.load(androidContext, apiKey)); + } + + /** + * Initialize a Bugsnag client + * + * @param androidContext an Android context, usually this + * @param configuration a configuration for the Client + */ + public Client(@NonNull Context androidContext, @NonNull final Configuration configuration) { + Context ctx = androidContext.getApplicationContext(); + appContext = ctx != null ? ctx : androidContext; + + connectivity = new ConnectivityCompat(appContext, new Function2() { + @Override + public Unit invoke(Boolean hasConnection, String networkState) { + Map data = new HashMap<>(); + data.put("hasConnection", hasConnection); + data.put("networkState", networkState); + leaveAutoBreadcrumb("Connectivity changed", BreadcrumbType.STATE, data); + if (hasConnection) { + eventStore.flushAsync(); + sessionTracker.flushAsync(); + } + return null; + } + }); + + // set sensible defaults for delivery/project packages etc if not set + immutableConfig = sanitiseConfiguration(appContext, configuration, connectivity); + logger = immutableConfig.getLogger(); + warnIfNotAppContext(androidContext); + + // Set up breadcrumbs + callbackState = configuration.impl.callbackState.copy(); + int maxBreadcrumbs = immutableConfig.getMaxBreadcrumbs(); + breadcrumbState = new BreadcrumbState(maxBreadcrumbs, callbackState, logger); + + storageManager = (StorageManager) appContext.getSystemService(Context.STORAGE_SERVICE); + + contextState = new ContextState(); + contextState.setContext(configuration.getContext()); + + sessionStore = new SessionStore(immutableConfig, logger, null); + sessionTracker = new SessionTracker(immutableConfig, callbackState, this, + sessionStore, logger, bgTaskService); + metadataState = copyMetadataState(configuration); + + ActivityManager am = + (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE); + + launchCrashTracker = new LaunchCrashTracker(immutableConfig); + appDataCollector = new AppDataCollector(appContext, appContext.getPackageManager(), + immutableConfig, sessionTracker, am, launchCrashTracker, logger); + + // load the device + user information + SharedPrefMigrator sharedPrefMigrator = new SharedPrefMigrator(appContext); + DeviceIdStore deviceIdStore = new DeviceIdStore(appContext, sharedPrefMigrator, logger); + String deviceId = deviceIdStore.loadDeviceId(); + UserStore userStore = new UserStore(immutableConfig, deviceId, sharedPrefMigrator, logger); + userState = userStore.load(configuration.getUser()); + sharedPrefMigrator.deleteLegacyPrefs(); + + DeviceBuildInfo info = DeviceBuildInfo.Companion.defaultInfo(); + Resources resources = appContext.getResources(); + deviceDataCollector = new DeviceDataCollector(connectivity, appContext, + resources, deviceId, info, Environment.getDataDirectory(), + new RootDetector(logger), bgTaskService, logger); + + if (appContext instanceof Application) { + Application application = (Application) appContext; + sessionLifecycleCallback = new SessionLifecycleCallback(sessionTracker); + application.registerActivityLifecycleCallbacks(sessionLifecycleCallback); + + if (immutableConfig.shouldRecordBreadcrumbType(BreadcrumbType.STATE)) { + this.activityBreadcrumbCollector = new ActivityBreadcrumbCollector( + new Function2, Unit>() { + @SuppressWarnings("unchecked") + @Override + public Unit invoke(String activity, Map metadata) { + leaveBreadcrumb(activity, (Map) metadata, + BreadcrumbType.STATE); + return null; + } + } + ); + application.registerActivityLifecycleCallbacks(activityBreadcrumbCollector); + } else { + this.activityBreadcrumbCollector = null; + } + } else { + this.activityBreadcrumbCollector = null; + this.sessionLifecycleCallback = null; + } + + InternalReportDelegate delegate = new InternalReportDelegate(appContext, logger, + immutableConfig, storageManager, appDataCollector, deviceDataCollector, + sessionTracker, notifier, bgTaskService); + eventStore = new EventStore(immutableConfig, logger, notifier, bgTaskService, delegate); + + deliveryDelegate = new DeliveryDelegate(logger, eventStore, + immutableConfig, breadcrumbState, notifier, bgTaskService); + + // Install a default exception handler with this client + if (immutableConfig.getEnabledErrorTypes().getUnhandledExceptions()) { + new ExceptionHandler(this, logger); + } + + // register a receiver for automatic breadcrumbs + systemBroadcastReceiver = SystemBroadcastReceiver.register(this, logger, bgTaskService); + + registerOrientationChangeListener(); + + // load last run info + lastRunInfoStore = new LastRunInfoStore(immutableConfig); + lastRunInfo = loadLastRunInfo(); + + // initialise plugins before attempting to flush any errors + loadPlugins(configuration); + + connectivity.registerForNetworkChanges(); + + // Flush any on-disk errors and sessions + eventStore.flushOnLaunch(); + eventStore.flushAsync(); + sessionTracker.flushAsync(); + + // leave auto breadcrumb + Map data = Collections.emptyMap(); + leaveAutoBreadcrumb("Bugsnag loaded", BreadcrumbType.STATE, data); + logger.d("Bugsnag loaded"); + } + + @VisibleForTesting + Client( + ImmutableConfig immutableConfig, + MetadataState metadataState, + ContextState contextState, + CallbackState callbackState, + UserState userState, + Context appContext, + @NonNull DeviceDataCollector deviceDataCollector, + @NonNull AppDataCollector appDataCollector, + @NonNull BreadcrumbState breadcrumbState, + @NonNull EventStore eventStore, + SessionStore sessionStore, + SystemBroadcastReceiver systemBroadcastReceiver, + SessionTracker sessionTracker, + ActivityBreadcrumbCollector activityBreadcrumbCollector, + SessionLifecycleCallback sessionLifecycleCallback, + Connectivity connectivity, + StorageManager storageManager, + Logger logger, + DeliveryDelegate deliveryDelegate, + LastRunInfoStore lastRunInfoStore, + LaunchCrashTracker launchCrashTracker + ) { + this.immutableConfig = immutableConfig; + this.metadataState = metadataState; + this.contextState = contextState; + this.callbackState = callbackState; + this.userState = userState; + this.appContext = appContext; + this.deviceDataCollector = deviceDataCollector; + this.appDataCollector = appDataCollector; + this.breadcrumbState = breadcrumbState; + this.eventStore = eventStore; + this.sessionStore = sessionStore; + this.systemBroadcastReceiver = systemBroadcastReceiver; + this.sessionTracker = sessionTracker; + this.activityBreadcrumbCollector = activityBreadcrumbCollector; + this.sessionLifecycleCallback = sessionLifecycleCallback; + this.connectivity = connectivity; + this.storageManager = storageManager; + this.logger = logger; + this.deliveryDelegate = deliveryDelegate; + this.lastRunInfoStore = lastRunInfoStore; + this.launchCrashTracker = launchCrashTracker; + this.lastRunInfo = null; + } + + private LastRunInfo loadLastRunInfo() { + LastRunInfo lastRunInfo = lastRunInfoStore.load(); + LastRunInfo currentRunInfo = new LastRunInfo(0, false, false); + persistRunInfo(currentRunInfo); + return lastRunInfo; + } + + /** + * Load information about the last run, and reset the persisted information to the defaults. + */ + private void persistRunInfo(final LastRunInfo runInfo) { + try { + bgTaskService.submitTask(TaskType.IO, new Runnable() { + @Override + public void run() { + lastRunInfoStore.persist(runInfo); + } + }); + } catch (RejectedExecutionException exc) { + logger.w("Failed to persist last run info", exc); + } + } + + private void loadPlugins(@NonNull Configuration configuration) { + NativeInterface.setClient(this); + Set userPlugins = configuration.getPlugins(); + pluginClient = new PluginClient(userPlugins, immutableConfig, logger); + pluginClient.loadPlugins(this); + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to client." + property + ", ignoring"); + } + + private MetadataState copyMetadataState(@NonNull Configuration configuration) { + // performs deep copy of metadata to preserve immutability of Configuration interface + Metadata orig = configuration.impl.metadataState.getMetadata(); + Metadata copy = orig.copy(); + return configuration.impl.metadataState.copy(copy); + } + + private void registerOrientationChangeListener() { + IntentFilter configFilter = new IntentFilter(); + configFilter.addAction(Intent.ACTION_CONFIGURATION_CHANGED); + ConfigChangeReceiver receiver = new ConfigChangeReceiver(deviceDataCollector, + new Function2() { + @Override + public Unit invoke(String oldOrientation, String newOrientation) { + Map data = new HashMap<>(); + data.put("from", oldOrientation); + data.put("to", newOrientation); + leaveAutoBreadcrumb("Orientation changed", BreadcrumbType.STATE, data); + clientObservable.postOrientationChange(newOrientation); + return null; + } + } + ); + ContextExtensionsKt.registerReceiverSafe(appContext, receiver, configFilter, logger); + } + + void setupNdkPlugin() { + String lastRunInfoPath = lastRunInfoStore.getFile().getAbsolutePath(); + int crashes = (lastRunInfo != null) ? lastRunInfo.getConsecutiveLaunchCrashes() : 0; + clientObservable.postNdkInstall(immutableConfig, lastRunInfoPath, crashes); + syncInitialState(); + clientObservable.postNdkDeliverPending(); + } + + void registerObserver(Observer observer) { + metadataState.addObserver(observer); + breadcrumbState.addObserver(observer); + sessionTracker.addObserver(observer); + clientObservable.addObserver(observer); + userState.addObserver(observer); + contextState.addObserver(observer); + deliveryDelegate.addObserver(observer); + launchCrashTracker.addObserver(observer); + } + + /** + * Sends initial state values for Metadata/User/Context to any registered observers. + */ + void syncInitialState() { + metadataState.emitObservableEvent(); + contextState.emitObservableEvent(); + userState.emitObservableEvent(); + } + + /** + * Starts tracking a new session. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to start a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This will start a new session even if there is already an existing + * session; you should call {@link #resumeSession()} if you only want to start a session + * when one doesn't already exist. + * + * @see #resumeSession() + * @see #pauseSession() + * @see Configuration#setAutoTrackSessions(boolean) + */ + public void startSession() { + sessionTracker.startSession(false); + } + + /** + * Pauses tracking of a session. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to pause a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This can be advantageous if, for example, you do not wish the + * stability score to include crashes in a background service. + * + * @see #startSession() + * @see #resumeSession() + * @see Configuration#setAutoTrackSessions(boolean) + */ + public void pauseSession() { + sessionTracker.pauseSession(); + } + + /** + * Resumes a session which has previously been paused, or starts a new session if none exists. + * If a session has already been resumed or started and has not been paused, calling this + * method will have no effect. You should disable automatic session tracking via + * {@link Configuration#setAutoTrackSessions(boolean)} if you call this method. + *

+ * It's important to note that sessions are stored in memory for the lifetime of the + * application process and are not persisted on disk. Therefore calling this method on app + * startup would start a new session, rather than continuing any previous session. + *

+ * You should call this at the appropriate time in your application when you wish to resume + * a previously started session. Any subsequent errors which occur in your application will + * still be reported to Bugsnag but will not count towards your application's + * + * stability score. + * + * @see #startSession() + * @see #pauseSession() + * @see Configuration#setAutoTrackSessions(boolean) + * + * @return true if a previous session was resumed, false if a new session was started. + */ + public boolean resumeSession() { + return sessionTracker.resumeSession(); + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + @Nullable public String getContext() { + return contextState.getContext(); + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + public void setContext(@Nullable String context) { + contextState.setContext(context); + } + + /** + * Sets the user associated with the event. + */ + @Override + public void setUser(@Nullable String id, @Nullable String email, @Nullable String name) { + userState.setUser(new User(id, email, name)); + } + + /** + * Returns the currently set User information. + */ + @NonNull + @Override + public User getUser() { + return userState.getUser(); + } + + /** + * Add a "on error" callback, to execute code at the point where an error report is + * captured in Bugsnag. + * + * You can use this to add or modify information attached to an Event + * before it is sent to your dashboard. You can also return + * false from any callback to prevent delivery. "on error" + * callbacks do not run before reports generated in the event + * of immediate app termination from crashes in C/C++ code. + * + * For example: + * + * Bugsnag.addOnError(new OnErrorCallback() { + * public boolean run(Event event) { + * event.setSeverity(Severity.INFO); + * return true; + * } + * }) + * + * @param onError a callback to run before sending errors to Bugsnag + * @see OnErrorCallback + */ + @Override + public void addOnError(@NonNull OnErrorCallback onError) { + if (onError != null) { + callbackState.addOnError(onError); + } else { + logNull("addOnError"); + } + } + + /** + * Removes a previously added "on error" callback + * @param onError the callback to remove + */ + @Override + public void removeOnError(@NonNull OnErrorCallback onError) { + if (onError != null) { + callbackState.removeOnError(onError); + } else { + logNull("removeOnError"); + } + } + + /** + * Add an "on breadcrumb" callback, to execute code before every + * breadcrumb captured by Bugsnag. + * + * You can use this to modify breadcrumbs before they are stored by Bugsnag. + * You can also return false from any callback to ignore a breadcrumb. + * + * For example: + * + * Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() { + * public boolean run(Breadcrumb breadcrumb) { + * return false; // ignore the breadcrumb + * } + * }) + * + * @param onBreadcrumb a callback to run before a breadcrumb is captured + * @see OnBreadcrumbCallback + */ + @Override + public void addOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { + if (onBreadcrumb != null) { + callbackState.addOnBreadcrumb(onBreadcrumb); + } else { + logNull("addOnBreadcrumb"); + } + } + + /** + * Removes a previously added "on breadcrumb" callback + * @param onBreadcrumb the callback to remove + */ + @Override + public void removeOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { + if (onBreadcrumb != null) { + callbackState.removeOnBreadcrumb(onBreadcrumb); + } else { + logNull("removeOnBreadcrumb"); + } + } + + /** + * Add an "on session" callback, to execute code before every + * session captured by Bugsnag. + * + * You can use this to modify sessions before they are stored by Bugsnag. + * You can also return false from any callback to ignore a session. + * + * For example: + * + * Bugsnag.onSession(new OnSessionCallback() { + * public boolean run(Session session) { + * return false; // ignore the session + * } + * }) + * + * @param onSession a callback to run before a session is captured + * @see OnSessionCallback + */ + @Override + public void addOnSession(@NonNull OnSessionCallback onSession) { + if (onSession != null) { + callbackState.addOnSession(onSession); + } else { + logNull("addOnSession"); + } + } + + /** + * Removes a previously added "on session" callback + * @param onSession the callback to remove + */ + @Override + public void removeOnSession(@NonNull OnSessionCallback onSession) { + if (onSession != null) { + callbackState.removeOnSession(onSession); + } else { + logNull("removeOnSession"); + } + } + + /** + * Notify Bugsnag of a handled exception + * + * @param exception the exception to send to Bugsnag + */ + public void notify(@NonNull Throwable exception) { + notify(exception, null); + } + + /** + * Notify Bugsnag of a handled exception + * + * @param exc the exception to send to Bugsnag + * @param onError callback invoked on the generated error report for + * additional modification + */ + public void notify(@NonNull Throwable exc, @Nullable OnErrorCallback onError) { + if (exc != null) { + SeverityReason severityReason = SeverityReason.newInstance(REASON_HANDLED_EXCEPTION); + Metadata metadata = metadataState.getMetadata(); + Event event = new Event(exc, immutableConfig, severityReason, metadata, logger); + populateAndNotifyAndroidEvent(event, onError); + } else { + logNull("notify"); + } + } + + /** + * Caches an error then attempts to notify. + * + * Should only ever be called from the {@link ExceptionHandler}. + */ + void notifyUnhandledException(@NonNull Throwable exc, Metadata metadata, + @SeverityReason.SeverityReasonType String severityReason, + @Nullable String attributeValue) { + SeverityReason handledState + = SeverityReason.newInstance(severityReason, Severity.ERROR, attributeValue); + Metadata data = Metadata.Companion.merge(metadataState.getMetadata(), metadata); + Event event = new Event(exc, immutableConfig, handledState, data, logger); + populateAndNotifyAndroidEvent(event, null); + + // persist LastRunInfo so that on relaunch users can check the app crashed + int consecutiveLaunchCrashes = lastRunInfo == null ? 0 + : lastRunInfo.getConsecutiveLaunchCrashes(); + boolean launching = launchCrashTracker.isLaunching(); + if (launching) { + consecutiveLaunchCrashes += 1; + } + LastRunInfo runInfo = new LastRunInfo(consecutiveLaunchCrashes, true, launching); + persistRunInfo(runInfo); + + // suspend execution of any further background tasks, waiting for previously + // submitted ones to complete. + bgTaskService.shutdown(); + } + + void populateAndNotifyAndroidEvent(@NonNull Event event, + @Nullable OnErrorCallback onError) { + // Capture the state of the app and device and attach diagnostics to the event + event.setDevice(deviceDataCollector.generateDeviceWithState(new Date().getTime())); + event.addMetadata("device", deviceDataCollector.getDeviceMetadata()); + + // add additional info that belongs in metadata + // generate new object each time, as this can be mutated by end-users + event.setApp(appDataCollector.generateAppWithState()); + event.addMetadata("app", appDataCollector.getAppDataMetadata()); + + // Attach breadcrumbState to the event + event.setBreadcrumbs(new ArrayList<>(breadcrumbState.getStore())); + + // Attach user info to the event + User user = userState.getUser(); + event.setUser(user.getId(), user.getEmail(), user.getName()); + + // Attach default context from active activity + if (Intrinsics.isEmpty(event.getContext())) { + String context = contextState.getContext(); + event.setContext(context != null ? context : appDataCollector.getActiveScreenClass()); + } + notifyInternal(event, onError); + } + + void notifyInternal(@NonNull Event event, + @Nullable OnErrorCallback onError) { + String type = event.getImpl().getSeverityReasonType(); + logger.d("Client#notifyInternal() - event captured by Client, type=" + type); + // Don't notify if this event class should be ignored + if (event.shouldDiscardClass()) { + logger.d("Skipping notification - should not notify for this class"); + return; + } + + if (!immutableConfig.shouldNotifyForReleaseStage()) { + logger.d("Skipping notification - should not notify for this release stage"); + return; + } + + // set the redacted keys on the event as this + // will not have been set for RN/Unity events + Set redactedKeys = metadataState.getMetadata().getRedactedKeys(); + Metadata eventMetadata = event.getImpl().getMetadata(); + eventMetadata.setRedactedKeys(redactedKeys); + + // get session for event + Session currentSession = sessionTracker.getCurrentSession(); + + if (currentSession != null + && (immutableConfig.getAutoTrackSessions() || !currentSession.isAutoCaptured())) { + event.setSession(currentSession); + } + + // Run on error tasks, don't notify if any return false + if (!callbackState.runOnErrorTasks(event, logger) + || (onError != null && !onError.onError(event))) { + logger.d("Skipping notification - onError task returned false"); + return; + } + + deliveryDelegate.deliver(event); + } + + /** + * Returns the current buffer of breadcrumbs that will be sent with captured events. This + * ordered list represents the most recent breadcrumbs to be captured up to the limit + * set in {@link Configuration#getMaxBreadcrumbs()}. + * + * The returned collection is readonly and mutating the list will cause no effect on the + * Client's state. If you wish to alter the breadcrumbs collected by the Client then you should + * use {@link Configuration#setEnabledBreadcrumbTypes(Set)} and + * {@link Configuration#addOnBreadcrumb(OnBreadcrumbCallback)} instead. + * + * @return a list of collected breadcrumbs + */ + @NonNull + public List getBreadcrumbs() { + return new ArrayList<>(breadcrumbState.getStore()); + } + + @NonNull + AppDataCollector getAppDataCollector() { + return appDataCollector; + } + + @NonNull + DeviceDataCollector getDeviceDataCollector() { + return deviceDataCollector; + } + + /** + * Adds a map of multiple metadata key-value pairs to the specified section. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull Map value) { + if (section != null && value != null) { + metadataState.addMetadata(section, value); + } else { + logNull("addMetadata"); + } + } + + /** + * Adds the specified key and value in the specified section. The value can be of + * any primitive type or a collection such as a map, set or array. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull String key, @Nullable Object value) { + if (section != null && key != null) { + metadataState.addMetadata(section, key, value); + + } else { + logNull("addMetadata"); + } + } + + /** + * Removes all the data from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section) { + if (section != null) { + metadataState.clearMetadata(section); + } else { + logNull("clearMetadata"); + } + } + + /** + * Removes data with the specified key from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + metadataState.clearMetadata(section, key); + } else { + logNull("clearMetadata"); + } + } + + /** + * Returns a map of data in the specified section. + */ + @Nullable + @Override + public Map getMetadata(@NonNull String section) { + if (section != null) { + return metadataState.getMetadata(section); + } else { + logNull("getMetadata"); + return null; + } + } + + /** + * Returns the value of the specified key in the specified section. + */ + @Override + @Nullable + public Object getMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + return metadataState.getMetadata(section, key); + } else { + logNull("getMetadata"); + return null; + } + } + + @NonNull + Map getMetadata() { + return metadataState.getMetadata().toMap(); + } + + /** + * Leave a "breadcrumb" log message, representing an action that occurred + * in your app, to aid with debugging. + * + * @param message the log message to leave + */ + public void leaveBreadcrumb(@NonNull String message) { + if (message != null) { + breadcrumbState.add(new Breadcrumb(message, logger)); + } else { + logNull("leaveBreadcrumb"); + } + } + + /** + * Leave a "breadcrumb" log message representing an action or event which + * occurred in your app, to aid with debugging + * @param message A short label + * @param metadata Additional diagnostic information about the app environment + * @param type A category for the breadcrumb + */ + public void leaveBreadcrumb(@NonNull String message, + @NonNull Map metadata, + @NonNull BreadcrumbType type) { + if (message != null && type != null && metadata != null) { + breadcrumbState.add(new Breadcrumb(message, type, metadata, new Date(), logger)); + } else { + logNull("leaveBreadcrumb"); + } + } + + /** + * Intended for internal use only - leaves a breadcrumb if the type is enabled for automatic + * breadcrumbs. + * + * @param message A short label + * @param type A category for the breadcrumb + * @param metadata Additional diagnostic information about the app environment + */ + void leaveAutoBreadcrumb(@NonNull String message, + @NonNull BreadcrumbType type, + @NonNull Map metadata) { + if (immutableConfig.shouldRecordBreadcrumbType(type)) { + breadcrumbState.add(new Breadcrumb(message, type, metadata, new Date(), logger)); + } + } + + /** + * Retrieves information about the last launch of the application, if it has been run before. + * + * For example, this allows checking whether the app crashed on its last launch, which could + * be used to perform conditional behaviour to recover from crashes, such as clearing the + * app data cache. + */ + @Nullable + public LastRunInfo getLastRunInfo() { + return lastRunInfo; + } + + /** + * Informs Bugsnag that the application has finished launching. Once this has been called + * {@link AppWithState#isLaunching()} will always be false in any new error reports, + * and synchronous delivery will not be attempted on the next launch for any fatal crashes. + * + * By default this method will be called after Bugsnag is initialized when + * {@link Configuration#getLaunchDurationMillis()} has elapsed. Invoking this method manually + * has precedence over the value supplied via the launchDurationMillis configuration option. + */ + public void markLaunchCompleted() { + launchCrashTracker.markLaunchCompleted(); + } + + SessionTracker getSessionTracker() { + return sessionTracker; + } + + @NonNull + EventStore getEventStore() { + return eventStore; + } + + /** + * Finalize by removing the receiver + * + * @throws Throwable if something goes wrong + */ + @SuppressWarnings("checkstyle:NoFinalizer") + protected void finalize() throws Throwable { + if (systemBroadcastReceiver != null) { + try { + ContextExtensionsKt.unregisterReceiverSafe(appContext, + systemBroadcastReceiver, logger); + } catch (IllegalArgumentException exception) { + logger.w("Receiver not registered"); + } + } + super.finalize(); + } + + private void warnIfNotAppContext(Context androidContext) { + if (!(androidContext instanceof Application)) { + logger.w("Warning - Non-Application context detected! Please ensure that you are " + + "initializing Bugsnag from a custom Application class."); + } + } + + ImmutableConfig getConfig() { + return immutableConfig; + } + + void setBinaryArch(String binaryArch) { + getAppDataCollector().setBinaryArch(binaryArch); + } + + Context getAppContext() { + return appContext; + } + + /** + * Intended for internal use only - sets the code bundle id for React Native + */ + @Nullable + String getCodeBundleId() { + return appDataCollector.getCodeBundleId(); + } + + /** + * Intended for internal use only - sets the code bundle id for React Native + */ + void setCodeBundleId(@Nullable String codeBundleId) { + appDataCollector.setCodeBundleId(codeBundleId); + } + + void addRuntimeVersionInfo(@NonNull String key, @NonNull String value) { + deviceDataCollector.addRuntimeVersionInfo(key, value); + } + + @VisibleForTesting + void close() { + connectivity.unregisterForNetworkChanges(); + bgTaskService.shutdown(); + } + + Logger getLogger() { + return logger; + } + + /** + * Retrieves an instantiated plugin of the given type, or null if none has been created + */ + @SuppressWarnings("rawtypes") + @Nullable + Plugin getPlugin(@NonNull Class clz) { + Set plugins = pluginClient.getPlugins(); + for (Plugin plugin : plugins) { + if (plugin.getClass().equals(clz)) { + return plugin; + } + } + return null; + } + + Notifier getNotifier() { + return notifier; + } + + MetadataState getMetadataState() { + return metadataState; + } +} diff --git a/app/src/main/java/com/bugsnag/android/ClientObservable.kt b/app/src/main/java/com/bugsnag/android/ClientObservable.kt new file mode 100644 index 0000000000..a654a54e24 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ClientObservable.kt @@ -0,0 +1,26 @@ +package com.bugsnag.android + +internal class ClientObservable : BaseObservable() { + + fun postOrientationChange(orientation: String?) { + notifyObservers(StateEvent.UpdateOrientation(orientation)) + } + + fun postNdkInstall(conf: ImmutableConfig, lastRunInfoPath: String, consecutiveLaunchCrashes: Int) { + notifyObservers( + StateEvent.Install( + conf.apiKey, + conf.enabledErrorTypes.ndkCrashes, + conf.appVersion, + conf.buildUuid, + conf.releaseStage, + lastRunInfoPath, + consecutiveLaunchCrashes + ) + ) + } + + fun postNdkDeliverPending() { + notifyObservers(StateEvent.DeliverPending) + } +} diff --git a/app/src/main/java/com/bugsnag/android/CollectionUtils.java b/app/src/main/java/com/bugsnag/android/CollectionUtils.java new file mode 100644 index 0000000000..b0ca8f363f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/CollectionUtils.java @@ -0,0 +1,19 @@ +package com.bugsnag.android; + +import androidx.annotation.Nullable; + +import java.util.Collection; + +class CollectionUtils { + static boolean containsNullElements(@Nullable Collection data) { + if (data == null) { + return true; + } + for (T datum : data) { + if (datum == null) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/com/bugsnag/android/ConfigChangeReceiver.kt b/app/src/main/java/com/bugsnag/android/ConfigChangeReceiver.kt new file mode 100644 index 0000000000..ba87ccfc9f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ConfigChangeReceiver.kt @@ -0,0 +1,22 @@ +package com.bugsnag.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +internal class ConfigChangeReceiver( + private val deviceDataCollector: DeviceDataCollector, + private val cb: (oldOrientation: String?, newOrientation: String?) -> Unit +) : BroadcastReceiver() { + + var orientation = deviceDataCollector.calculateOrientation() + + override fun onReceive(context: Context?, intent: Intent?) { + val newOrientation = deviceDataCollector.calculateOrientation() + + if (!newOrientation.equals(orientation)) { + cb(orientation, newOrientation) + orientation = newOrientation + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/ConfigInternal.kt b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt new file mode 100644 index 0000000000..9e60eb1262 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt @@ -0,0 +1,95 @@ +package com.bugsnag.android + +import android.content.Context +import java.io.File + +internal class ConfigInternal(var apiKey: String) : CallbackAware, MetadataAware, UserAware { + + private var user = User() + + @JvmField + internal val callbackState: CallbackState = CallbackState() + + @JvmField + internal val metadataState: MetadataState = MetadataState() + + var appVersion: String? = null + var versionCode: Int? = 0 + var releaseStage: String? = null + var sendThreads: ThreadSendPolicy = ThreadSendPolicy.ALWAYS + var persistUser: Boolean = false + + var launchDurationMillis: Long = DEFAULT_LAUNCH_CRASH_THRESHOLD_MS + + var autoTrackSessions: Boolean = true + var sendLaunchCrashesSynchronously: Boolean = true + var enabledErrorTypes: ErrorTypes = ErrorTypes() + var autoDetectErrors: Boolean = true + var appType: String? = "android" + var logger: Logger? = DebugLogger + set(value) { + field = value ?: NoopLogger + } + var delivery: Delivery? = null + var endpoints: EndpointConfiguration = EndpointConfiguration() + var maxBreadcrumbs: Int = DEFAULT_MAX_BREADCRUMBS + var maxPersistedEvents: Int = DEFAULT_MAX_PERSISTED_EVENTS + var maxPersistedSessions: Int = DEFAULT_MAX_PERSISTED_SESSIONS + var context: String? = null + + var redactedKeys: Set = metadataState.metadata.redactedKeys + set(value) { + metadataState.metadata.redactedKeys = value + field = value + } + + var discardClasses: Set = emptySet() + var enabledReleaseStages: Set? = null + var enabledBreadcrumbTypes: Set? = BreadcrumbType.values().toSet() + var projectPackages: Set = emptySet() + var persistenceDirectory: File? = null + + protected val plugins = mutableSetOf() + + override fun addOnError(onError: OnErrorCallback) = callbackState.addOnError(onError) + override fun removeOnError(onError: OnErrorCallback) = callbackState.removeOnError(onError) + override fun addOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) = + callbackState.addOnBreadcrumb(onBreadcrumb) + override fun removeOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) = + callbackState.removeOnBreadcrumb(onBreadcrumb) + override fun addOnSession(onSession: OnSessionCallback) = callbackState.addOnSession(onSession) + override fun removeOnSession(onSession: OnSessionCallback) = callbackState.removeOnSession(onSession) + + override fun addMetadata(section: String, value: Map) = + metadataState.addMetadata(section, value) + override fun addMetadata(section: String, key: String, value: Any?) = + metadataState.addMetadata(section, key, value) + override fun clearMetadata(section: String) = metadataState.clearMetadata(section) + override fun clearMetadata(section: String, key: String) = metadataState.clearMetadata(section, key) + override fun getMetadata(section: String) = metadataState.getMetadata(section) + override fun getMetadata(section: String, key: String) = metadataState.getMetadata(section, key) + + override fun getUser(): User = user + override fun setUser(id: String?, email: String?, name: String?) { + user = User(id, email, name) + } + + fun addPlugin(plugin: Plugin) { + plugins.add(plugin) + } + + companion object { + private const val DEFAULT_MAX_BREADCRUMBS = 25 + private const val DEFAULT_MAX_PERSISTED_SESSIONS = 128 + private const val DEFAULT_MAX_PERSISTED_EVENTS = 32 + private const val DEFAULT_LAUNCH_CRASH_THRESHOLD_MS: Long = 5000 + + @JvmStatic + fun load(context: Context): Configuration = load(context, null) + + @JvmStatic + protected fun load(context: Context, apiKey: String?): Configuration { + return ManifestConfigLoader().load(context, apiKey) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Configuration.java b/app/src/main/java/com/bugsnag/android/Configuration.java new file mode 100644 index 0000000000..c4dc5a5a01 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Configuration.java @@ -0,0 +1,968 @@ +package com.bugsnag.android; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * User-specified configuration storage object, contains information + * specified at the client level, api-key and endpoint configuration. + */ +@SuppressWarnings("ConstantConditions") // suppress warning about making redundant null checks +public class Configuration implements CallbackAware, MetadataAware, UserAware { + + private static final int MIN_BREADCRUMBS = 0; + private static final int MAX_BREADCRUMBS = 100; + private static final String API_KEY_REGEX = "[A-Fa-f0-9]{32}"; + private static final long MIN_LAUNCH_CRASH_THRESHOLD_MS = 0; + + final ConfigInternal impl; + + /** + * Constructs a new Configuration object with default values. + */ + public Configuration(@NonNull String apiKey) { + validateApiKey(apiKey); + impl = new ConfigInternal(apiKey); + } + + /** + * Loads a Configuration object from values supplied as meta-data elements in your + * AndroidManifest. + */ + @NonNull + public static Configuration load(@NonNull Context context) { + return ConfigInternal.load(context); + } + + @NonNull + static Configuration load(@NonNull Context context, @NonNull String apiKey) { + return ConfigInternal.load(context, apiKey); + } + + private void validateApiKey(String value) { + if (Intrinsics.isEmpty(value)) { + throw new IllegalArgumentException("No Bugsnag API Key set"); + } + + if (!value.matches(API_KEY_REGEX)) { + DebugLogger.INSTANCE.w(String.format("Invalid configuration. apiKey should be a " + + "32-character hexademical string, got \"%s\"", value)); + } + } + + private void logNull(String property) { + getLogger().e("Invalid null value supplied to config." + property + ", ignoring"); + } + + /** + * Retrieves the API key used for events sent to Bugsnag. + */ + @NonNull + public String getApiKey() { + return impl.getApiKey(); + } + + /** + * Changes the API key used for events sent to Bugsnag. + */ + public void setApiKey(@NonNull String apiKey) { + validateApiKey(apiKey); + impl.setApiKey(apiKey); + } + + /** + * Set the application version sent to Bugsnag. We'll automatically pull your app version + * from the versionName field in your AndroidManifest.xml file. + */ + @Nullable + public String getAppVersion() { + return impl.getAppVersion(); + } + + /** + * Set the application version sent to Bugsnag. We'll automatically pull your app version + * from the versionName field in your AndroidManifest.xml file. + */ + public void setAppVersion(@Nullable String appVersion) { + impl.setAppVersion(appVersion); + } + + /** + * We'll automatically pull your versionCode from the versionCode field + * in your AndroidManifest.xml file. If you'd like to override this you + * can set this property. + */ + @Nullable + public Integer getVersionCode() { + return impl.getVersionCode(); + } + + /** + * We'll automatically pull your versionCode from the versionCode field + * in your AndroidManifest.xml file. If you'd like to override this you + * can set this property. + */ + public void setVersionCode(@Nullable Integer versionCode) { + impl.setVersionCode(versionCode); + } + + /** + * If you would like to distinguish between errors that happen in different stages of the + * application release process (development, production, etc) you can set the releaseStage + * that is reported to Bugsnag. + * + * If you are running a debug build, we'll automatically set this to "development", + * otherwise it is set to "production". You can control whether events are sent for + * specific release stages using the enabledReleaseStages option. + */ + @Nullable + public String getReleaseStage() { + return impl.getReleaseStage(); + } + + /** + * If you would like to distinguish between errors that happen in different stages of the + * application release process (development, production, etc) you can set the releaseStage + * that is reported to Bugsnag. + * + * If you are running a debug build, we'll automatically set this to "development", + * otherwise it is set to "production". You can control whether events are sent for + * specific release stages using the enabledReleaseStages option. + */ + public void setReleaseStage(@Nullable String releaseStage) { + impl.setReleaseStage(releaseStage); + } + + /** + * Controls whether we should capture and serialize the state of all threads at the time + * of an error. + * + * By default sendThreads is set to Thread.ThreadSendPolicy.ALWAYS. This can be set to + * Thread.ThreadSendPolicy.NEVER to disable or Thread.ThreadSendPolicy.UNHANDLED_ONLY + * to only do so for unhandled errors. + */ + @NonNull + public ThreadSendPolicy getSendThreads() { + return impl.getSendThreads(); + } + + /** + * Controls whether we should capture and serialize the state of all threads at the time + * of an error. + * + * By default sendThreads is set to Thread.ThreadSendPolicy.ALWAYS. This can be set to + * Thread.ThreadSendPolicy.NEVER to disable or Thread.ThreadSendPolicy.UNHANDLED_ONLY + * to only do so for unhandled errors. + */ + public void setSendThreads(@NonNull ThreadSendPolicy sendThreads) { + if (sendThreads != null) { + impl.setSendThreads(sendThreads); + } else { + logNull("sendThreads"); + } + } + + /** + * Set whether or not Bugsnag should persist user information between application sessions. + * + * If enabled then any user information set will be re-used until the user information is + * removed manually by calling {@link Bugsnag#setUser(String, String, String)} + * with null arguments. + */ + public boolean getPersistUser() { + return impl.getPersistUser(); + } + + /** + * Set whether or not Bugsnag should persist user information between application sessions. + * + * If enabled then any user information set will be re-used until the user information is + * removed manually by calling {@link Bugsnag#setUser(String, String, String)} + * with null arguments. + */ + public void setPersistUser(boolean persistUser) { + impl.setPersistUser(persistUser); + } + + /** + * Sets the directory where event and session JSON payloads should be persisted if a network + * request is not successful. If you use Bugsnag in multiple processes, then a unique + * persistenceDirectory must be configured for each process to prevent duplicate + * requests being made by each instantiation of Bugsnag. + *

+ * The persistenceDirectory also stores user information if {@link #getPersistUser()} has been + * set to true. + *

+ * By default, bugsnag sets the persistenceDirectory to {@link Context#getCacheDir()}. + *

+ * If the persistenceDirectory is changed between application launches, no attempt will be made + * to deliver events or sessions cached in the previous location. + */ + @Nullable + public File getPersistenceDirectory() { + return impl.getPersistenceDirectory(); + } + + /** + * Sets the directory where event and session JSON payloads should be persisted if a network + * request is not successful. If you use Bugsnag in multiple processes, then a unique + * persistenceDirectory must be configured for each process to prevent duplicate + * requests being made by each instantiation of Bugsnag. + *

+ * The persistenceDirectory also stores user information if {@link #getPersistUser()} has been + * set to true. + *

+ * By default, bugsnag sets the persistenceDirectory to {@link Context#getCacheDir()}. + *

+ * If the persistenceDirectory is changed between application launches, no attempt will be made + * to deliver events or sessions cached in the previous location. + */ + public void setPersistenceDirectory(@Nullable File directory) { + impl.setPersistenceDirectory(directory); + } + + /** + * Deprecated. Use {@link #getLaunchDurationMillis()} instead. + */ + @Deprecated + public long getLaunchCrashThresholdMs() { + getLogger().w("The launchCrashThresholdMs configuration option is deprecated " + + "and will be removed in a future release. Please use " + + "launchDurationMillis instead."); + return getLaunchDurationMillis(); + } + + /** + * Deprecated. Use {@link #setLaunchDurationMillis(long)} instead. + */ + @Deprecated + public void setLaunchCrashThresholdMs(long launchCrashThresholdMs) { + getLogger().w("The launchCrashThresholdMs configuration option is deprecated " + + "and will be removed in a future release. Please use " + + "launchDurationMillis instead."); + setLaunchDurationMillis(launchCrashThresholdMs); + } + + /** + * Sets whether or not Bugsnag should send crashes synchronously that occurred during + * the application's launch period. By default this behavior is enabled. + * + * See {@link #setLaunchDurationMillis(long)} + */ + public boolean getSendLaunchCrashesSynchronously() { + return impl.getSendLaunchCrashesSynchronously(); + } + + /** + * Sets whether or not Bugsnag should send crashes synchronously that occurred during + * the application's launch period. By default this behavior is enabled. + * + * See {@link #setLaunchDurationMillis(long)} + */ + public void setSendLaunchCrashesSynchronously(boolean sendLaunchCrashesSynchronously) { + impl.setSendLaunchCrashesSynchronously(sendLaunchCrashesSynchronously); + } + + /** + * Sets the threshold in milliseconds for an uncaught error to be considered as a crash on + * launch. If a crash is detected on launch, Bugsnag will attempt to send the most recent + * event synchronously. + * + * By default, this value is set at 5,000ms. Setting the value to 0 will count all crashes + * as launch crashes until markLaunchCompleted() is called. + */ + public long getLaunchDurationMillis() { + return impl.getLaunchDurationMillis(); + } + + /** + * Sets the threshold in milliseconds for an uncaught error to be considered as a crash on + * launch. If a crash is detected on launch, Bugsnag will attempt to send the most recent + * event synchronously. + * + * By default, this value is set at 5,000ms. Setting the value to 0 will count all crashes + * as launch crashes until markLaunchCompleted() is called. + */ + public void setLaunchDurationMillis(long launchDurationMillis) { + if (launchDurationMillis >= MIN_LAUNCH_CRASH_THRESHOLD_MS) { + impl.setLaunchDurationMillis(launchDurationMillis); + } else { + getLogger().e(String.format(Locale.US, "Invalid configuration value detected. " + + "Option launchDurationMillis should be a positive long value." + + "Supplied value is %d", launchDurationMillis)); + } + } + + /** + * Sets whether or not Bugsnag should automatically capture and report User sessions whenever + * the app enters the foreground. + * + * By default this behavior is enabled. + */ + public boolean getAutoTrackSessions() { + return impl.getAutoTrackSessions(); + } + + /** + * Sets whether or not Bugsnag should automatically capture and report User sessions whenever + * the app enters the foreground. + * + * By default this behavior is enabled. + */ + public void setAutoTrackSessions(boolean autoTrackSessions) { + impl.setAutoTrackSessions(autoTrackSessions); + } + + /** + * Bugsnag will automatically detect different types of error in your application. + * If you wish to control exactly which types are enabled, set this property. + */ + @NonNull + public ErrorTypes getEnabledErrorTypes() { + return impl.getEnabledErrorTypes(); + } + + /** + * Bugsnag will automatically detect different types of error in your application. + * If you wish to control exactly which types are enabled, set this property. + */ + public void setEnabledErrorTypes(@NonNull ErrorTypes enabledErrorTypes) { + if (enabledErrorTypes != null) { + impl.setEnabledErrorTypes(enabledErrorTypes); + } else { + logNull("enabledErrorTypes"); + } + } + + /** + * If you want to disable automatic detection of all errors, you can set this property to false. + * By default this property is true. + * + * Setting autoDetectErrors to false will disable all automatic errors, regardless of the + * error types enabled by enabledErrorTypes + */ + public boolean getAutoDetectErrors() { + return impl.getAutoDetectErrors(); + } + + /** + * If you want to disable automatic detection of all errors, you can set this property to false. + * By default this property is true. + * + * Setting autoDetectErrors to false will disable all automatic errors, regardless of the + * error types enabled by enabledErrorTypes + */ + public void setAutoDetectErrors(boolean autoDetectErrors) { + impl.setAutoDetectErrors(autoDetectErrors); + } + + /** + * If your app's codebase contains different entry-points/processes, but reports to a single + * Bugsnag project, you might want to add information denoting the type of process the error + * came from. + * + * This information can be used in the dashboard to filter errors and to determine whether + * an error is limited to a subset of appTypes. + * + * By default, this value is set to 'android'. + */ + @Nullable + public String getAppType() { + return impl.getAppType(); + } + + /** + * If your app's codebase contains different entry-points/processes, but reports to a single + * Bugsnag project, you might want to add information denoting the type of process the error + * came from. + * + * This information can be used in the dashboard to filter errors and to determine whether + * an error is limited to a subset of appTypes. + * + * By default, this value is set to 'android'. + */ + public void setAppType(@Nullable String appType) { + impl.setAppType(appType); + } + + /** + * By default, the notifier's log messages will be logged using android.util.Log + * with a "Bugsnag" tag unless the releaseStage is "production". + * + * To override this behavior, an alternative instance can be provided that implements the + * Logger interface. + */ + @Nullable + public Logger getLogger() { + return impl.getLogger(); + } + + /** + * By default, the notifier's log messages will be logged using android.util.Log + * with a "Bugsnag" tag unless the releaseStage is "production". + * + * To override this behavior, an alternative instance can be provided that implements the + * Logger interface. + */ + public void setLogger(@Nullable Logger logger) { + impl.setLogger(logger); + } + + /** + * The Delivery implementation used to make network calls to the Bugsnag + * Error Reporting and + * Sessions API. + * + * This may be useful if you have requirements such as certificate pinning and rotation, + * which are not supported by the default implementation. + * + * To provide custom delivery functionality, create a class which implements the Delivery + * interface. Please note that request bodies must match the structure specified in the + * Error Reporting and + * Sessions API documentation. + * + * You can use the return type from the deliver functions to control the strategy for + * retrying the transmission at a later date. + * + * If DeliveryStatus.UNDELIVERED is returned, the notifier will automatically cache + * the payload and trigger delivery later on. Otherwise, if either DeliveryStatus.DELIVERED + * or DeliveryStatus.FAILURE is returned the notifier will removed any cached payload + * and no further delivery will be attempted. + */ + @NonNull + public Delivery getDelivery() { + return impl.getDelivery(); + } + + /** + * The Delivery implementation used to make network calls to the Bugsnag + * Error Reporting and + * Sessions API. + * + * This may be useful if you have requirements such as certificate pinning and rotation, + * which are not supported by the default implementation. + * + * To provide custom delivery functionality, create a class which implements the Delivery + * interface. Please note that request bodies must match the structure specified in the + * Error Reporting and + * Sessions API documentation. + * + * You can use the return type from the deliver functions to control the strategy for + * retrying the transmission at a later date. + * + * If DeliveryStatus.UNDELIVERED is returned, the notifier will automatically cache + * the payload and trigger delivery later on. Otherwise, if either DeliveryStatus.DELIVERED + * or DeliveryStatus.FAILURE is returned the notifier will removed any cached payload + * and no further delivery will be attempted. + */ + public void setDelivery(@NonNull Delivery delivery) { + if (delivery != null) { + impl.setDelivery(delivery); + } else { + logNull("delivery"); + } + } + + /** + * Set the endpoints to send data to. By default we'll send error reports to + * https://notify.bugsnag.com, and sessions to https://sessions.bugsnag.com, but you can + * override this if you are using Bugsnag Enterprise to point to your own Bugsnag endpoints. + */ + @NonNull + public EndpointConfiguration getEndpoints() { + return impl.getEndpoints(); + } + + /** + * Set the endpoints to send data to. By default we'll send error reports to + * https://notify.bugsnag.com, and sessions to https://sessions.bugsnag.com, but you can + * override this if you are using Bugsnag Enterprise to point to your own Bugsnag endpoints. + */ + public void setEndpoints(@NonNull EndpointConfiguration endpoints) { + if (endpoints != null) { + impl.setEndpoints(endpoints); + } else { + logNull("endpoints"); + } + } + + /** + * Sets the maximum number of breadcrumbs which will be stored. Once the threshold is reached, + * the oldest breadcrumbs will be deleted. + * + * By default, 25 breadcrumbs are stored: this can be amended up to a maximum of 100. + */ + public int getMaxBreadcrumbs() { + return impl.getMaxBreadcrumbs(); + } + + /** + * Sets the maximum number of breadcrumbs which will be stored. Once the threshold is reached, + * the oldest breadcrumbs will be deleted. + * + * By default, 25 breadcrumbs are stored: this can be amended up to a maximum of 100. + */ + public void setMaxBreadcrumbs(int maxBreadcrumbs) { + if (maxBreadcrumbs >= MIN_BREADCRUMBS && maxBreadcrumbs <= MAX_BREADCRUMBS) { + impl.setMaxBreadcrumbs(maxBreadcrumbs); + } else { + getLogger().e(String.format(Locale.US, "Invalid configuration value detected. " + + "Option maxBreadcrumbs should be an integer between 0-100. " + + "Supplied value is %d", maxBreadcrumbs)); + } + } + + /** + * Sets the maximum number of persisted events which will be stored. Once the threshold is + * reached, the oldest event will be deleted. + * + * By default, 32 events are persisted. + */ + public int getMaxPersistedEvents() { + return impl.getMaxPersistedEvents(); + } + + /** + * Sets the maximum number of persisted events which will be stored. Once the threshold is + * reached, the oldest event will be deleted. + * + * By default, 32 events are persisted. + */ + public void setMaxPersistedEvents(int maxPersistedEvents) { + if (maxPersistedEvents >= 0) { + impl.setMaxPersistedEvents(maxPersistedEvents); + } else { + getLogger().e(String.format(Locale.US, "Invalid configuration value detected. " + + "Option maxPersistedEvents should be a positive integer." + + "Supplied value is %d", maxPersistedEvents)); + } + } + + /** + * Sets the maximum number of persisted sessions which will be stored. Once the threshold is + * reached, the oldest session will be deleted. + * + * By default, 128 sessions are persisted. + */ + public int getMaxPersistedSessions() { + return impl.getMaxPersistedSessions(); + } + + /** + * Sets the maximum number of persisted sessions which will be stored. Once the threshold is + * reached, the oldest session will be deleted. + * + * By default, 128 sessions are persisted. + */ + public void setMaxPersistedSessions(int maxPersistedSessions) { + if (maxPersistedSessions >= 0) { + impl.setMaxPersistedSessions(maxPersistedSessions); + } else { + getLogger().e(String.format(Locale.US, "Invalid configuration value detected. " + + "Option maxPersistedSessions should be a positive integer." + + "Supplied value is %d", maxPersistedSessions)); + } + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + @Nullable + public String getContext() { + return impl.getContext(); + } + + /** + * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts + * represent what was happening in your application at the time an error occurs. + * + * In an android app the "context" is automatically set as the foreground Activity. + * If you would like to set this value manually, you should alter this property. + */ + public void setContext(@Nullable String context) { + impl.setContext(context); + } + + /** + * Sets which values should be removed from any Metadata objects before + * sending them to Bugsnag. Use this if you want to ensure you don't send + * sensitive data such as passwords, and credit card numbers to our + * servers. Any keys which contain these strings will be filtered. + * + * By default, redactedKeys is set to "password" + */ + @NonNull + public Set getRedactedKeys() { + return impl.getRedactedKeys(); + } + + /** + * Sets which values should be removed from any Metadata objects before + * sending them to Bugsnag. Use this if you want to ensure you don't send + * sensitive data such as passwords, and credit card numbers to our + * servers. Any keys which contain these strings will be filtered. + * + * By default, redactedKeys is set to "password" + */ + public void setRedactedKeys(@NonNull Set redactedKeys) { + if (CollectionUtils.containsNullElements(redactedKeys)) { + logNull("redactedKeys"); + } else { + impl.setRedactedKeys(redactedKeys); + } + } + + /** + * Allows you to specify the fully-qualified name of error classes that will be discarded + * before being sent to Bugsnag if they are detected. The notifier performs an exact + * match against the canonical class name. + */ + @NonNull + public Set getDiscardClasses() { + return impl.getDiscardClasses(); + } + + /** + * Allows you to specify the fully-qualified name of error classes that will be discarded + * before being sent to Bugsnag if they are detected. The notifier performs an exact + * match against the canonical class name. + */ + public void setDiscardClasses(@NonNull Set discardClasses) { + if (CollectionUtils.containsNullElements(discardClasses)) { + logNull("discardClasses"); + } else { + impl.setDiscardClasses(discardClasses); + } + } + + /** + * By default, Bugsnag will be notified of events that happen in any releaseStage. + * If you would like to change which release stages notify Bugsnag you can set this property. + */ + @Nullable + public Set getEnabledReleaseStages() { + return impl.getEnabledReleaseStages(); + } + + /** + * By default, Bugsnag will be notified of events that happen in any releaseStage. + * If you would like to change which release stages notify Bugsnag you can set this property. + */ + public void setEnabledReleaseStages(@Nullable Set enabledReleaseStages) { + impl.setEnabledReleaseStages(enabledReleaseStages); + } + + /** + * By default we will automatically add breadcrumbs for common application events such as + * activity lifecycle events and system intents. To amend this behavior, + * override the enabled breadcrumb types. All breadcrumbs can be disabled by providing an + * empty set. + * + * The following breadcrumb types can be enabled: + * + * - Captured errors: left when an error event is sent to the Bugsnag API. + * - Manual breadcrumbs: left via the Bugsnag.leaveBreadcrumb function. + * - Navigation changes: left for Activity Lifecycle events to track the user's journey in + * the app. + * - State changes: state breadcrumbs are left for system broadcast events. For example: + * battery warnings, airplane mode, etc. + * - User interaction: left when the user performs certain system operations. + */ + @Nullable + public Set getEnabledBreadcrumbTypes() { + return impl.getEnabledBreadcrumbTypes(); + } + + /** + * By default we will automatically add breadcrumbs for common application events such as + * activity lifecycle events and system intents. To amend this behavior, + * override the enabled breadcrumb types. All breadcrumbs can be disabled by providing an + * empty set. + * + * The following breadcrumb types can be enabled: + * + * - Captured errors: left when an error event is sent to the Bugsnag API. + * - Manual breadcrumbs: left via the Bugsnag.leaveBreadcrumb function. + * - Navigation changes: left for Activity Lifecycle events to track the user's journey in + * the app. + * - State changes: state breadcrumbs are left for system broadcast events. For example: + * battery warnings, airplane mode, etc. + * - User interaction: left when the user performs certain system operations. + */ + public void setEnabledBreadcrumbTypes(@Nullable Set enabledBreadcrumbTypes) { + impl.setEnabledBreadcrumbTypes(enabledBreadcrumbTypes); + } + + /** + * Sets which package names Bugsnag should consider as a part of the + * running application. We mark stacktrace lines as in-project if they + * originate from any of these packages and this allows us to improve + * the visual display of the stacktrace on the dashboard. + * + * By default, projectPackages is set to be the package you called Bugsnag.start from. + */ + @NonNull + public Set getProjectPackages() { + return impl.getProjectPackages(); + } + + /** + * Sets which package names Bugsnag should consider as a part of the + * running application. We mark stacktrace lines as in-project if they + * originate from any of these packages and this allows us to improve + * the visual display of the stacktrace on the dashboard. + * + * By default, projectPackages is set to be the package you called Bugsnag.start from. + */ + public void setProjectPackages(@NonNull Set projectPackages) { + if (CollectionUtils.containsNullElements(projectPackages)) { + logNull("projectPackages"); + } else { + impl.setProjectPackages(projectPackages); + } + } + + /** + * Add a "on error" callback, to execute code at the point where an error report is + * captured in Bugsnag. + * + * You can use this to add or modify information attached to an Event + * before it is sent to your dashboard. You can also return + * false from any callback to prevent delivery. "on error" + * callbacks do not run before reports generated in the event + * of immediate app termination from crashes in C/C++ code. + * + * For example: + * + * Bugsnag.addOnError(new OnErrorCallback() { + * public boolean run(Event event) { + * event.setSeverity(Severity.INFO); + * return true; + * } + * }) + * + * @param onError a callback to run before sending errors to Bugsnag + * @see OnErrorCallback + */ + @Override + public void addOnError(@NonNull OnErrorCallback onError) { + if (onError != null) { + impl.addOnError(onError); + } else { + logNull("addOnError"); + } + } + + /** + * Removes a previously added "on error" callback + * @param onError the callback to remove + */ + @Override + public void removeOnError(@NonNull OnErrorCallback onError) { + if (onError != null) { + impl.removeOnError(onError); + } else { + logNull("removeOnError"); + } + } + + /** + * Add an "on breadcrumb" callback, to execute code before every + * breadcrumb captured by Bugsnag. + * + * You can use this to modify breadcrumbs before they are stored by Bugsnag. + * You can also return false from any callback to ignore a breadcrumb. + * + * For example: + * + * Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() { + * public boolean run(Breadcrumb breadcrumb) { + * return false; // ignore the breadcrumb + * } + * }) + * + * @param onBreadcrumb a callback to run before a breadcrumb is captured + * @see OnBreadcrumbCallback + */ + @Override + public void addOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { + if (onBreadcrumb != null) { + impl.addOnBreadcrumb(onBreadcrumb); + } else { + logNull("addOnBreadcrumb"); + } + } + + /** + * Removes a previously added "on breadcrumb" callback + * @param onBreadcrumb the callback to remove + */ + @Override + public void removeOnBreadcrumb(@NonNull OnBreadcrumbCallback onBreadcrumb) { + if (onBreadcrumb != null) { + impl.removeOnBreadcrumb(onBreadcrumb); + } else { + logNull("removeOnBreadcrumb"); + } + } + + /** + * Add an "on session" callback, to execute code before every + * session captured by Bugsnag. + * + * You can use this to modify sessions before they are stored by Bugsnag. + * You can also return false from any callback to ignore a session. + * + * For example: + * + * Bugsnag.onSession(new OnSessionCallback() { + * public boolean run(Session session) { + * return false; // ignore the session + * } + * }) + * + * @param onSession a callback to run before a session is captured + * @see OnSessionCallback + */ + @Override + public void addOnSession(@NonNull OnSessionCallback onSession) { + if (onSession != null) { + impl.addOnSession(onSession); + } else { + logNull("addOnSession"); + } + } + + /** + * Removes a previously added "on session" callback + * @param onSession the callback to remove + */ + @Override + public void removeOnSession(@NonNull OnSessionCallback onSession) { + if (onSession != null) { + impl.removeOnSession(onSession); + } else { + logNull("removeOnSession"); + } + } + + /** + * Adds a map of multiple metadata key-value pairs to the specified section. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull Map value) { + if (section != null && value != null) { + impl.addMetadata(section, value); + } else { + logNull("addMetadata"); + } + } + + /** + * Adds the specified key and value in the specified section. The value can be of + * any primitive type or a collection such as a map, set or array. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull String key, @Nullable Object value) { + if (section != null && key != null) { + impl.addMetadata(section, key, value); + } else { + logNull("addMetadata"); + } + } + + /** + * Removes all the data from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section) { + if (section != null) { + impl.clearMetadata(section); + } else { + logNull("clearMetadata"); + } + } + + /** + * Removes data with the specified key from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + impl.clearMetadata(section, key); + } else { + logNull("clearMetadata"); + } + } + + /** + * Returns a map of data in the specified section. + */ + @Nullable + @Override + public Map getMetadata(@NonNull String section) { + if (section != null) { + return impl.getMetadata(section); + } else { + logNull("getMetadata"); + return null; + } + } + + /** + * Returns the value of the specified key in the specified section. + */ + @Nullable + @Override + public Object getMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + return impl.getMetadata(section, key); + } else { + logNull("getMetadata"); + return null; + } + } + + /** + * Returns the currently set User information. + */ + @NonNull + @Override + public User getUser() { + return impl.getUser(); + } + + /** + * Sets the user associated with the event. + */ + @Override + public void setUser(@Nullable String id, @Nullable String email, @Nullable String name) { + impl.setUser(id, email, name); + } + + /** + * Adds a plugin which will be loaded when the bugsnag notifier is instantiated. + */ + public void addPlugin(@NonNull Plugin plugin) { + if (plugin != null) { + impl.addPlugin(plugin); + } else { + logNull("addPlugin"); + } + } + + Set getPlugins() { + return impl.getPlugins(); + } +} diff --git a/app/src/main/java/com/bugsnag/android/ConnectivityCompat.kt b/app/src/main/java/com/bugsnag/android/ConnectivityCompat.kt new file mode 100644 index 0000000000..bbd501d499 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ConnectivityCompat.kt @@ -0,0 +1,127 @@ +package com.bugsnag.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import androidx.annotation.RequiresApi + +internal typealias NetworkChangeCallback = (hasConnection: Boolean, networkState: String) -> Unit + +internal interface Connectivity { + fun registerForNetworkChanges() + fun unregisterForNetworkChanges() + fun hasNetworkConnection(): Boolean + fun retrieveNetworkAccessState(): String +} + +internal class ConnectivityCompat( + context: Context, + callback: NetworkChangeCallback? +) : Connectivity { + + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val connectivity: Connectivity = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> ConnectivityApi24(cm, callback) + else -> ConnectivityLegacy(context, cm, callback) + } + + override fun registerForNetworkChanges() { + runCatching { connectivity.registerForNetworkChanges() } + } + + override fun hasNetworkConnection(): Boolean { + val result = runCatching { connectivity.hasNetworkConnection() } + return result.getOrElse { true } // allow network requests to be made if state unknown + } + + override fun unregisterForNetworkChanges() { + runCatching { connectivity.unregisterForNetworkChanges() } + } + + override fun retrieveNetworkAccessState(): String { + val result = runCatching { connectivity.retrieveNetworkAccessState() } + return result.getOrElse { "unknown" } + } +} + +@Suppress("DEPRECATION") +internal class ConnectivityLegacy( + private val context: Context, + private val cm: ConnectivityManager, + callback: NetworkChangeCallback? +) : Connectivity { + + private val changeReceiver = ConnectivityChangeReceiver(callback) + + override fun registerForNetworkChanges() { + val intentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + context.registerReceiverSafe(changeReceiver, intentFilter) + } + + override fun unregisterForNetworkChanges() = context.unregisterReceiverSafe(changeReceiver) + + override fun hasNetworkConnection(): Boolean { + return cm.activeNetworkInfo?.isConnectedOrConnecting ?: false + } + + override fun retrieveNetworkAccessState(): String { + return when (cm.activeNetworkInfo?.type) { + null -> "none" + ConnectivityManager.TYPE_WIFI -> "wifi" + ConnectivityManager.TYPE_ETHERNET -> "ethernet" + else -> "cellular" // all other types are cellular in some form + } + } + + private inner class ConnectivityChangeReceiver(private val cb: NetworkChangeCallback?) : + BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + cb?.invoke(hasNetworkConnection(), retrieveNetworkAccessState()) + } + } +} + +@RequiresApi(Build.VERSION_CODES.N) +internal class ConnectivityApi24( + private val cm: ConnectivityManager, + callback: NetworkChangeCallback? +) : Connectivity { + + private val networkCallback = ConnectivityTrackerCallback(callback) + + override fun registerForNetworkChanges() = cm.registerDefaultNetworkCallback(networkCallback) + override fun unregisterForNetworkChanges() = cm.unregisterNetworkCallback(networkCallback) + override fun hasNetworkConnection() = cm.activeNetwork != null + + override fun retrieveNetworkAccessState(): String { + val network = cm.activeNetwork + val capabilities = if (network != null) cm.getNetworkCapabilities(network) else null + + return when { + capabilities == null -> "none" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "unknown" + } + } + + private inner class ConnectivityTrackerCallback(private val cb: NetworkChangeCallback?) : + ConnectivityManager.NetworkCallback() { + override fun onUnavailable() { + super.onUnavailable() + cb?.invoke(false, retrieveNetworkAccessState()) + } + + override fun onAvailable(network: Network) { + super.onAvailable(network) + cb?.invoke(true, retrieveNetworkAccessState()) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/ContextExtensions.kt b/app/src/main/java/com/bugsnag/android/ContextExtensions.kt new file mode 100644 index 0000000000..a1d8a52ce8 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ContextExtensions.kt @@ -0,0 +1,47 @@ +package com.bugsnag.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.RemoteException + +/** + * Calls [Context.registerReceiver] but swallows [SecurityException] and [RemoteException] + * to avoid terminating the process in rare cases where the registration is unsuccessful. + */ +internal fun Context.registerReceiverSafe( + receiver: BroadcastReceiver?, + filter: IntentFilter?, + logger: Logger? = null +): Intent? { + try { + return registerReceiver(receiver, filter) + } catch (exc: SecurityException) { + logger?.w("Failed to register receiver", exc) + } catch (exc: RemoteException) { + logger?.w("Failed to register receiver", exc) + } catch (exc: IllegalArgumentException) { + logger?.w("Failed to register receiver", exc) + } + return null +} + +/** + * Calls [Context.unregisterReceiver] but swallows [SecurityException] and [RemoteException] + * to avoid terminating the process in rare cases where the registration is unsuccessful. + */ +internal fun Context.unregisterReceiverSafe( + receiver: BroadcastReceiver?, + logger: Logger? = null +) { + try { + unregisterReceiver(receiver) + } catch (exc: SecurityException) { + logger?.w("Failed to register receiver", exc) + } catch (exc: RemoteException) { + logger?.w("Failed to register receiver", exc) + } catch (exc: IllegalArgumentException) { + logger?.w("Failed to register receiver", exc) + } +} diff --git a/app/src/main/java/com/bugsnag/android/ContextState.kt b/app/src/main/java/com/bugsnag/android/ContextState.kt new file mode 100644 index 0000000000..47ca70cc3c --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ContextState.kt @@ -0,0 +1,13 @@ +package com.bugsnag.android + +internal class ContextState(context: String? = null) : BaseObservable() { + var context = context + set(value) { + field = value + emitObservableEvent() + } + + fun emitObservableEvent() = notifyObservers(StateEvent.UpdateContext(context)) + + fun copy() = ContextState(context) +} diff --git a/app/src/main/java/com/bugsnag/android/DateUtils.java b/app/src/main/java/com/bugsnag/android/DateUtils.java new file mode 100644 index 0000000000..406e16d19b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DateUtils.java @@ -0,0 +1,42 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +class DateUtils { + // SimpleDateFormat isn't thread safe, cache one instance per thread as needed. + private static final ThreadLocal iso8601Holder = new ThreadLocal() { + @NonNull + @Override + protected DateFormat initialValue() { + TimeZone tz = TimeZone.getTimeZone("UTC"); + DateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + iso8601.setTimeZone(tz); + return iso8601; + } + }; + + @NonNull + static String toIso8601(@NonNull Date date) { + DateFormat dateFormat = iso8601Holder.get(); + if (dateFormat == null) { + throw new IllegalStateException("Unable to find valid dateformatter"); + } + return dateFormat.format(date); + } + + @NonNull + static Date fromIso8601(@NonNull String date) { + try { + return iso8601Holder.get().parse(date); + } catch (ParseException exc) { + throw new IllegalArgumentException("Failed to parse timestamp", exc); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/DebugLogger.kt b/app/src/main/java/com/bugsnag/android/DebugLogger.kt new file mode 100644 index 0000000000..e50feca334 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DebugLogger.kt @@ -0,0 +1,40 @@ +package com.bugsnag.android + +import android.util.Log + +internal object DebugLogger : Logger { + + private const val TAG = "Bugsnag" + + override fun e(msg: String) { + Log.e(TAG, msg) + } + + override fun e(msg: String, throwable: Throwable) { + Log.e(TAG, msg, throwable) + } + + override fun w(msg: String) { + Log.w(TAG, msg) + } + + override fun w(msg: String, throwable: Throwable) { + Log.w(TAG, msg, throwable) + } + + override fun i(msg: String) { + Log.i(TAG, msg) + } + + override fun i(msg: String, throwable: Throwable) { + Log.i(TAG, msg, throwable) + } + + override fun d(msg: String) { + Log.d(TAG, msg) + } + + override fun d(msg: String, throwable: Throwable) { + Log.d(TAG, msg, throwable) + } +} diff --git a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt new file mode 100644 index 0000000000..a7995164cb --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt @@ -0,0 +1,134 @@ +package com.bugsnag.android + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.PrintWriter +import java.net.HttpURLConnection +import java.net.HttpURLConnection.HTTP_BAD_REQUEST +import java.net.HttpURLConnection.HTTP_CLIENT_TIMEOUT +import java.net.HttpURLConnection.HTTP_OK +import java.net.URL + +/** + * Converts a [JsonStream.Streamable] into JSON, placing it in a [ByteArray] + */ +internal fun serializeJsonPayload(streamable: JsonStream.Streamable): ByteArray { + return ByteArrayOutputStream().use { baos -> + JsonStream(PrintWriter(baos).buffered()).use(streamable::toStream) + baos.toByteArray() + } +} + +internal class DefaultDelivery( + private val connectivity: Connectivity?, + val logger: Logger +) : Delivery { + + override fun deliver(payload: Session, deliveryParams: DeliveryParams): DeliveryStatus { + val status = deliver(deliveryParams.endpoint, payload, deliveryParams.headers) + logger.i("Session API request finished with status $status") + return status + } + + override fun deliver(payload: EventPayload, deliveryParams: DeliveryParams): DeliveryStatus { + val status = deliver(deliveryParams.endpoint, payload, deliveryParams.headers) + logger.i("Error API request finished with status $status") + return status + } + + fun deliver( + urlString: String, + streamable: JsonStream.Streamable, + headers: Map + ): DeliveryStatus { + + if (connectivity != null && !connectivity.hasNetworkConnection()) { + return DeliveryStatus.UNDELIVERED + } + var conn: HttpURLConnection? = null + + try { + val json = serializeJsonPayload(streamable) + conn = makeRequest(URL(urlString), json, headers) + + // End the request, get the response code + val responseCode = conn.responseCode + val status = getDeliveryStatus(responseCode) + logRequestInfo(responseCode, conn, status) + return status + } catch (oom: OutOfMemoryError) { + // attempt to persist the payload on disk. This approach uses streams to write to a + // file, which takes less memory than serializing the payload into a ByteArray, and + // therefore has a reasonable chance of retaining the payload for future delivery. + logger.w("Encountered OOM delivering payload, falling back to persist on disk", oom) + return DeliveryStatus.UNDELIVERED + } catch (exception: IOException) { + logger.w("IOException encountered in request", exception) + return DeliveryStatus.UNDELIVERED + } catch (exception: Exception) { + logger.w("Unexpected error delivering payload", exception) + return DeliveryStatus.FAILURE + } finally { + conn?.disconnect() + } + } + + private fun makeRequest( + url: URL, + json: ByteArray, + headers: Map + ): HttpURLConnection { + val conn = url.openConnection() as HttpURLConnection + conn.doOutput = true + + // avoids creating a buffer within HttpUrlConnection, see + // https://developer.android.com/reference/java/net/HttpURLConnection + conn.setFixedLengthStreamingMode(json.size) + + // calculate the SHA-1 digest and add all other headers + computeSha1Digest(json)?.let { digest -> + conn.addRequestProperty(HEADER_BUGSNAG_INTEGRITY, digest) + } + headers.forEach { (key, value) -> + if (value != null) { + conn.addRequestProperty(key, value) + } + } + + // write the JSON payload + conn.outputStream.use { + it.write(json) + } + return conn + } + + private fun logRequestInfo(code: Int, conn: HttpURLConnection, status: DeliveryStatus) { + logger.i( + "Request completed with code $code, " + + "message: ${conn.responseMessage}, " + + "headers: ${conn.headerFields}" + ) + + conn.inputStream.bufferedReader().use { + logger.d("Received request response: ${it.readText()}") + } + + if (status != DeliveryStatus.DELIVERED) { + conn.errorStream.bufferedReader().use { + logger.w("Request error details: ${it.readText()}") + } + } + } + + internal fun getDeliveryStatus(responseCode: Int): DeliveryStatus { + val unrecoverableCodes = IntRange(HTTP_BAD_REQUEST, 499).filter { + it != HTTP_CLIENT_TIMEOUT && it != 429 + } + + return when (responseCode) { + in HTTP_OK..299 -> DeliveryStatus.DELIVERED + in unrecoverableCodes -> DeliveryStatus.FAILURE + else -> DeliveryStatus.UNDELIVERED + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Delivery.kt b/app/src/main/java/com/bugsnag/android/Delivery.kt new file mode 100644 index 0000000000..0fc2d2c748 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Delivery.kt @@ -0,0 +1,65 @@ +package com.bugsnag.android + +/** + * Implementations of this interface deliver Error Reports and Sessions captured to the Bugsnag API. + * + * A default [Delivery] implementation is provided as part of Bugsnag initialization, + * but you may wish to use your own implementation if you have requirements such + * as pinning SSL certificates, for example. + * + * Any custom implementation must be capable of sending + * [Error Reports](https://docs.bugsnag.com/api/error-reporting/) + * and [Sessions](https://docs.bugsnag.com/api/sessions/) as + * documented at [https://docs.bugsnag.com/api/](https://docs.bugsnag.com/api/) + * + * @see DefaultDelivery + */ +interface Delivery { + + /** + * Posts an array of sessions to the Bugsnag Session Tracking API. + * + * This request must be delivered to the endpoint specified in [deliveryParams] with the given + * HTTP headers. + * + * You should return the [DeliveryStatus] which best matches the end-result of your delivery + * attempt. Bugsnag will use the return value to decide whether to delete the payload if it was + * cached on disk, or whether to reattempt delivery later on. + * + * For example, a 2xx status code will indicate success so you should return + * [DeliveryStatus.DELIVERED]. Most 4xx status codes would indicate an unrecoverable error, so + * the report should be dropped using [DeliveryStatus.FAILURE]. For all other scenarios, + * delivery should be attempted again later by using [DeliveryStatus.UNDELIVERED]. + * + * See [https://docs.bugsnag.com/api/sessions/](https://docs.bugsnag.com/api/sessions/) + * + * @param payload The session tracking payload + * @param deliveryParams The delivery parameters to be used for this request + * @return the end-result of your delivery attempt + */ + fun deliver(payload: Session, deliveryParams: DeliveryParams): DeliveryStatus + + /** + * Posts an Error Report to the Bugsnag Error Reporting API. + * + * This request must be delivered to the endpoint specified in [deliveryParams] with the given + * HTTP headers. + * + * You should return the [DeliveryStatus] which best matches the end-result of your delivery + * attempt. Bugsnag will use the return value to decide whether to delete the payload if it was + * cached on disk, or whether to reattempt delivery later on. + * + * For example, a 2xx status code will indicate success so you should return + * [DeliveryStatus.DELIVERED]. Most 4xx status codes would indicate an unrecoverable error, so + * the report should be dropped using [DeliveryStatus.FAILURE]. For all other scenarios, + * delivery should be attempted again later by using [DeliveryStatus.UNDELIVERED]. + * + * See [https://docs.bugsnag.com/api/error-reporting/] + * (https://docs.bugsnag.com/api/error-reporting/) + * + * @param payload The error payload + * @param deliveryParams The delivery parameters to be used for this request + * @return the end-result of your delivery attempt + */ + fun deliver(payload: EventPayload, deliveryParams: DeliveryParams): DeliveryStatus +} diff --git a/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java b/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java new file mode 100644 index 0000000000..7f3af2bdd4 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java @@ -0,0 +1,134 @@ +package com.bugsnag.android; + +import static com.bugsnag.android.SeverityReason.REASON_PROMISE_REJECTION; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + +class DeliveryDelegate extends BaseObservable { + + final Logger logger; + private final EventStore eventStore; + private final ImmutableConfig immutableConfig; + final BreadcrumbState breadcrumbState; + private final Notifier notifier; + final BackgroundTaskService backgroundTaskService; + + DeliveryDelegate(Logger logger, + EventStore eventStore, + ImmutableConfig immutableConfig, + BreadcrumbState breadcrumbState, + Notifier notifier, + BackgroundTaskService backgroundTaskService) { + this.logger = logger; + this.eventStore = eventStore; + this.immutableConfig = immutableConfig; + this.breadcrumbState = breadcrumbState; + this.notifier = notifier; + this.backgroundTaskService = backgroundTaskService; + } + + void deliver(@NonNull Event event) { + logger.d("DeliveryDelegate#deliver() - event being stored/delivered by Client"); + // Build the eventPayload + String apiKey = event.getApiKey(); + EventPayload eventPayload = new EventPayload(apiKey, event, notifier, immutableConfig); + Session session = event.getSession(); + + if (session != null) { + if (event.isUnhandled()) { + event.setSession(session.incrementUnhandledAndCopy()); + notifyObservers(StateEvent.NotifyUnhandled.INSTANCE); + } else { + event.setSession(session.incrementHandledAndCopy()); + notifyObservers(StateEvent.NotifyHandled.INSTANCE); + } + } + + if (event.getImpl().getOriginalUnhandled()) { + // should only send unhandled errors if they don't terminate the process (i.e. ANRs) + String severityReasonType = event.getImpl().getSeverityReasonType(); + boolean promiseRejection = REASON_PROMISE_REJECTION.equals(severityReasonType); + boolean anr = event.getImpl().isAnr(event); + cacheEvent(event, anr || promiseRejection); + } else { + deliverPayloadAsync(event, eventPayload); + } + } + + private void deliverPayloadAsync(@NonNull Event event, EventPayload eventPayload) { + final EventPayload finalEventPayload = eventPayload; + final Event finalEvent = event; + + // Attempt to send the eventPayload in the background + try { + backgroundTaskService.submitTask(TaskType.ERROR_REQUEST, new Runnable() { + @Override + public void run() { + deliverPayloadInternal(finalEventPayload, finalEvent); + } + }); + } catch (RejectedExecutionException exception) { + cacheEvent(event, false); + logger.w("Exceeded max queue count, saving to disk to send later"); + } + } + + @VisibleForTesting + DeliveryStatus deliverPayloadInternal(@NonNull EventPayload payload, @NonNull Event event) { + logger.d("DeliveryDelegate#deliverPayloadInternal() - attempting event delivery"); + DeliveryParams deliveryParams = immutableConfig.getErrorApiDeliveryParams(payload); + Delivery delivery = immutableConfig.getDelivery(); + DeliveryStatus deliveryStatus = delivery.deliver(payload, deliveryParams); + + switch (deliveryStatus) { + case DELIVERED: + logger.i("Sent 1 new event to Bugsnag"); + leaveErrorBreadcrumb(event); + break; + case UNDELIVERED: + logger.w("Could not send event(s) to Bugsnag," + + " saving to disk to send later"); + cacheEvent(event, false); + leaveErrorBreadcrumb(event); + break; + case FAILURE: + logger.w("Problem sending event to Bugsnag"); + break; + default: + break; + } + return deliveryStatus; + } + + private void cacheEvent(@NonNull Event event, boolean attemptSend) { + eventStore.write(event); + if (attemptSend) { + eventStore.flushAsync(); + } + } + + private void leaveErrorBreadcrumb(@NonNull Event event) { + // Add a breadcrumb for this event occurring + List errors = event.getErrors(); + + if (errors.size() > 0) { + String errorClass = errors.get(0).getErrorClass(); + String message = errors.get(0).getErrorMessage(); + + Map data = new HashMap<>(); + data.put("errorClass", errorClass); + data.put("message", message); + data.put("unhandled", String.valueOf(event.isUnhandled())); + data.put("severity", event.getSeverity().toString()); + breadcrumbState.add(new Breadcrumb(errorClass, + BreadcrumbType.ERROR, data, new Date(), logger)); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeliveryHeaders.kt b/app/src/main/java/com/bugsnag/android/DeliveryHeaders.kt new file mode 100644 index 0000000000..cebc83bfc7 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeliveryHeaders.kt @@ -0,0 +1,82 @@ +package com.bugsnag.android + +import java.io.OutputStream +import java.security.DigestOutputStream +import java.security.MessageDigest +import java.util.Date + +private const val HEADER_API_PAYLOAD_VERSION = "Bugsnag-Payload-Version" +private const val HEADER_BUGSNAG_SENT_AT = "Bugsnag-Sent-At" +private const val HEADER_BUGSNAG_STACKTRACE_TYPES = "Bugsnag-Stacktrace-Types" +private const val HEADER_CONTENT_TYPE = "Content-Type" +internal const val HEADER_BUGSNAG_INTEGRITY = "Bugsnag-Integrity" +internal const val HEADER_API_KEY = "Bugsnag-Api-Key" +internal const val HEADER_INTERNAL_ERROR = "Bugsnag-Internal-Error" + +/** + * Supplies the headers which must be used in any request sent to the Error Reporting API. + * + * @return the HTTP headers + */ +internal fun errorApiHeaders(payload: EventPayload): Map { + val mutableHeaders = mutableMapOf( + HEADER_API_PAYLOAD_VERSION to "4.0", + HEADER_API_KEY to (payload.apiKey ?: ""), + HEADER_BUGSNAG_SENT_AT to DateUtils.toIso8601(Date()), + HEADER_CONTENT_TYPE to "application/json" + ) + val errorTypes = payload.getErrorTypes() + if (errorTypes.isNotEmpty()) { + mutableHeaders[HEADER_BUGSNAG_STACKTRACE_TYPES] = serializeErrorTypeHeader(errorTypes) + } + return mutableHeaders.toMap() +} + +/** + * Serializes the error types to a comma delimited string + */ +internal fun serializeErrorTypeHeader(errorTypes: Set): String { + return when { + errorTypes.isEmpty() -> "" + else -> + errorTypes + .map(ErrorType::desc) + .reduce { accumulator, str -> + "$accumulator,$str" + } + } +} + +/** + * Supplies the headers which must be used in any request sent to the Session Tracking API. + * + * @return the HTTP headers + */ +internal fun sessionApiHeaders(apiKey: String): Map = mapOf( + HEADER_API_PAYLOAD_VERSION to "1.0", + HEADER_API_KEY to apiKey, + HEADER_CONTENT_TYPE to "application/json", + HEADER_BUGSNAG_SENT_AT to DateUtils.toIso8601(Date()) +) + +internal fun computeSha1Digest(payload: ByteArray): String? { + runCatching { + val shaDigest = MessageDigest.getInstance("SHA-1") + val builder = StringBuilder("sha1 ") + + // Pipe the object through a no-op output stream + DigestOutputStream(NullOutputStream(), shaDigest).use { stream -> + stream.buffered().use { writer -> + writer.write(payload) + } + shaDigest.digest().forEach { byte -> + builder.append(String.format("%02x", byte)) + } + } + return builder.toString() + }.getOrElse { return null } +} + +internal class NullOutputStream : OutputStream() { + override fun write(b: Int) = Unit +} diff --git a/app/src/main/java/com/bugsnag/android/DeliveryParams.kt b/app/src/main/java/com/bugsnag/android/DeliveryParams.kt new file mode 100644 index 0000000000..7eac1c2785 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeliveryParams.kt @@ -0,0 +1,17 @@ +package com.bugsnag.android + +/** + * The parameters which should be used to deliver an Event/Session. + */ +class DeliveryParams( + + /** + * The endpoint to which the payload should be sent + */ + val endpoint: String, + + /** + * The HTTP headers which must be attached to the request + */ + val headers: Map +) diff --git a/app/src/main/java/com/bugsnag/android/DeliveryStatus.kt b/app/src/main/java/com/bugsnag/android/DeliveryStatus.kt new file mode 100644 index 0000000000..9bbd4d8ccf --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeliveryStatus.kt @@ -0,0 +1,23 @@ +package com.bugsnag.android + +/** + * Return value for the status of a payload delivery. + */ +enum class DeliveryStatus { + + /** + * The payload was delivered successfully and can be deleted. + */ + DELIVERED, + + /** + * The payload was not delivered but can be retried, e.g. when there was a loss of connectivity + */ + UNDELIVERED, + + /** + * + * The payload was not delivered and should be deleted without attempting retry. + */ + FAILURE +} diff --git a/app/src/main/java/com/bugsnag/android/Device.kt b/app/src/main/java/com/bugsnag/android/Device.kt new file mode 100644 index 0000000000..75478b27d5 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Device.kt @@ -0,0 +1,80 @@ +package com.bugsnag.android + +/** + * Stateless information set by the notifier about the device on which the event occurred can be + * found on this class. These values can be accessed and amended if necessary. + */ +open class Device internal constructor( + buildInfo: DeviceBuildInfo, + + /** + * The Application Binary Interface used + */ + var cpuAbi: Array?, + + /** + * Whether the device has been jailbroken + */ + var jailbroken: Boolean?, + + /** + * A UUID generated by Bugsnag and used for the individual application on a device + */ + var id: String?, + + /** + * The IETF language tag of the locale used + */ + var locale: String?, + + /** + * The total number of bytes of memory on the device + */ + var totalMemory: Long?, + + /** + * A collection of names and their versions of the primary languages, frameworks or + * runtimes that the application is running on + */ + var runtimeVersions: MutableMap? +) : JsonStream.Streamable { + + /** + * The manufacturer of the device used + */ + var manufacturer: String? = buildInfo.manufacturer + + /** + * The model name of the device used + */ + var model: String? = buildInfo.model + + /** + * The name of the operating system running on the device used + */ + var osName: String? = "android" + + /** + * The version of the operating system running on the device used + */ + var osVersion: String? = buildInfo.osVersion + + internal open fun serializeFields(writer: JsonStream) { + writer.name("cpuAbi").value(cpuAbi) + writer.name("jailbroken").value(jailbroken) + writer.name("id").value(id) + writer.name("locale").value(locale) + writer.name("manufacturer").value(manufacturer) + writer.name("model").value(model) + writer.name("osName").value(osName) + writer.name("osVersion").value(osVersion) + writer.name("runtimeVersions").value(runtimeVersions) + writer.name("totalMemory").value(totalMemory) + } + + override fun toStream(writer: JsonStream) { + writer.beginObject() + serializeFields(writer) + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceBuildInfo.kt b/app/src/main/java/com/bugsnag/android/DeviceBuildInfo.kt new file mode 100644 index 0000000000..39ed0fa205 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceBuildInfo.kt @@ -0,0 +1,36 @@ +package com.bugsnag.android + +import android.os.Build + +internal class DeviceBuildInfo( + val manufacturer: String?, + val model: String?, + val osVersion: String?, + val apiLevel: Int?, + val osBuild: String?, + val fingerprint: String?, + val tags: String?, + val brand: String?, + val cpuAbis: Array? +) { + companion object { + fun defaultInfo(): DeviceBuildInfo { + @Suppress("DEPRECATION") val cpuABis = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP -> Build.SUPPORTED_ABIS + else -> arrayOf(Build.CPU_ABI, Build.CPU_ABI2) + } + + return DeviceBuildInfo( + Build.MANUFACTURER, + Build.MODEL, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT, + Build.DISPLAY, + Build.FINGERPRINT, + Build.TAGS, + Build.BRAND, + cpuABis + ) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt b/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt new file mode 100644 index 0000000000..98aecee197 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceDataCollector.kt @@ -0,0 +1,260 @@ +package com.bugsnag.android + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.ORIENTATION_PORTRAIT +import android.content.res.Resources +import android.os.BatteryManager +import android.provider.Settings +import java.io.File +import java.util.Date +import java.util.HashMap +import java.util.Locale +import java.util.concurrent.Callable +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException +import kotlin.math.max +import kotlin.math.min + +internal class DeviceDataCollector( + private val connectivity: Connectivity, + private val appContext: Context, + private val resources: Resources?, + private val deviceId: String?, + private val buildInfo: DeviceBuildInfo, + private val dataDirectory: File, + rootDetector: RootDetector, + bgTaskService: BackgroundTaskService, + private val logger: Logger +) { + + private val displayMetrics = resources?.displayMetrics + private val emulator = isEmulator() + private val screenDensity = getScreenDensity() + private val dpi = getScreenDensityDpi() + private val screenResolution = getScreenResolution() + private val locale = Locale.getDefault().toString() + private val cpuAbi = getCpuAbi() + private val runtimeVersions: MutableMap + private val rootedFuture: Future? + + init { + val map = mutableMapOf() + buildInfo.apiLevel?.let { map["androidApiLevel"] = it } + buildInfo.osBuild?.let { map["osBuild"] = it } + runtimeVersions = map + + rootedFuture = try { + bgTaskService.submitTask( + TaskType.IO, + Callable { + rootDetector.isRooted() + } + ) + } catch (exc: RejectedExecutionException) { + logger.w("Failed to perform root detection checks", exc) + null + } + } + + fun generateDevice() = Device( + buildInfo, + cpuAbi, + checkIsRooted(), + deviceId, + locale, + calculateTotalMemory(), + runtimeVersions.toMutableMap() + ) + + fun generateDeviceWithState(now: Long) = DeviceWithState( + buildInfo, + checkIsRooted(), + deviceId, + locale, + calculateTotalMemory(), + runtimeVersions.toMutableMap(), + calculateFreeDisk(), + calculateFreeMemory(), + calculateOrientation(), + Date(now) + ) + + fun getDeviceMetadata(): Map { + val map = HashMap() + map["batteryLevel"] = getBatteryLevel() + map["charging"] = isCharging() + map["locationStatus"] = getLocationStatus() + map["networkAccess"] = getNetworkAccess() + map["brand"] = buildInfo.brand + map["screenDensity"] = screenDensity + map["dpi"] = dpi + map["emulator"] = emulator + map["screenResolution"] = screenResolution + return map + } + + private fun checkIsRooted(): Boolean { + return try { + rootedFuture != null && rootedFuture.get() + } catch (exc: Exception) { + false + } + } + + /** + * Guesses whether the current device is an emulator or not, erring on the side of caution + * + * @return true if the current device is an emulator + */ + private // genymotion + fun isEmulator(): Boolean { + val fingerprint = buildInfo.fingerprint + return fingerprint != null && ( + fingerprint.startsWith("unknown") || + fingerprint.contains("generic") || + fingerprint.contains("vbox") + ) + } + + /** + * The screen density of the current Android device in dpi, eg. 320 + */ + private fun getScreenDensityDpi(): Int? = displayMetrics?.densityDpi + + /** + * Get the current battery charge level, eg 0.3 + */ + private fun getBatteryLevel(): Float? { + try { + val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = appContext.registerReceiverSafe(null, ifilter, logger) + + if (batteryStatus != null) { + return batteryStatus.getIntExtra( + "level", + -1 + ) / batteryStatus.getIntExtra("scale", -1).toFloat() + } + } catch (exception: Exception) { + logger.w("Could not get batteryLevel") + } + return null + } + + /** + * Is the device currently charging/full battery? + */ + private fun isCharging(): Boolean? { + try { + val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = appContext.registerReceiverSafe(null, ifilter, logger) + + if (batteryStatus != null) { + val status = batteryStatus.getIntExtra("status", -1) + return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL + } + } catch (exception: Exception) { + logger.w("Could not get charging status") + } + return null + } + + /** + * Get the current status of location services + */ + private fun getLocationStatus(): String? { + try { + val cr = appContext.contentResolver + @Suppress("DEPRECATION") val providersAllowed = + Settings.Secure.getString(cr, Settings.Secure.LOCATION_PROVIDERS_ALLOWED) + return when { + providersAllowed != null && providersAllowed.isNotEmpty() -> "allowed" + else -> "disallowed" + } + } catch (exception: Exception) { + logger.w("Could not get locationStatus") + } + return null + } + + /** + * Get the current status of network access, eg "cellular" + */ + private fun getNetworkAccess(): String = connectivity.retrieveNetworkAccessState() + + /** + * The screen density scaling factor of the current Android device + */ + private fun getScreenDensity(): Float? = displayMetrics?.density + + /** + * The screen resolution of the current Android device in px, eg. 1920x1080 + */ + private fun getScreenResolution(): String? { + return if (displayMetrics != null) { + val max = max(displayMetrics.widthPixels, displayMetrics.heightPixels) + val min = min(displayMetrics.widthPixels, displayMetrics.heightPixels) + String.format(Locale.US, "%dx%d", max, min) + } else { + null + } + } + + /** + * Gets information about the CPU / API + */ + fun getCpuAbi(): Array = buildInfo.cpuAbis ?: emptyArray() + + /** + * Get the usable disk space on internal storage's data directory + */ + @SuppressLint("UsableSpace") + fun calculateFreeDisk(): Long { + // for this specific case we want the currently usable space, not + // StorageManager#allocatableBytes() as the UsableSpace lint inspection suggests + return dataDirectory.usableSpace + } + + /** + * Get the amount of memory remaining that the VM can allocate + */ + private fun calculateFreeMemory(): Long { + val runtime = Runtime.getRuntime() + val maxMemory = runtime.maxMemory() + + return if (maxMemory != Long.MAX_VALUE) { + maxMemory - runtime.totalMemory() + runtime.freeMemory() + } else { + runtime.freeMemory() + } + } + + /** + * Get the total memory available on the current Android device, in bytes + */ + private fun calculateTotalMemory(): Long { + val runtime = Runtime.getRuntime() + val maxMemory = runtime.maxMemory() + return when { + maxMemory != Long.MAX_VALUE -> maxMemory + else -> runtime.totalMemory() + } + } + + /** + * Get the device orientation, eg. "landscape" + */ + internal fun calculateOrientation() = when (resources?.configuration?.orientation) { + ORIENTATION_LANDSCAPE -> "landscape" + ORIENTATION_PORTRAIT -> "portrait" + else -> null + } + + fun addRuntimeVersionInfo(key: String, value: String) { + runtimeVersions[key] = value + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt b/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt new file mode 100644 index 0000000000..5581acabf5 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceIdStore.kt @@ -0,0 +1,181 @@ +package com.bugsnag.android + +import android.content.Context +import android.util.JsonReader +import java.io.File +import java.io.IOException +import java.lang.Thread +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.util.UUID + +/** + * This class is responsible for persisting and retrieving the device ID which uniquely + * identifies this device. + * + * This class is made multi-process safe through the use of a [FileLock], and thread safe + * through the use of a [ReadWriteLock] in [SynchronizedStreamableStore]. + */ +internal class DeviceIdStore @JvmOverloads constructor( + context: Context, + private val file: File = File(context.filesDir, "device-id"), + private val sharedPrefMigrator: SharedPrefMigrator, + private val logger: Logger +) { + + private val synchronizedStreamableStore: SynchronizedStreamableStore + + init { + try { + if (!file.exists()) { + file.createNewFile() + } + } catch (exc: Throwable) { + logger.w("Failed to created device ID file", exc) + } + this.synchronizedStreamableStore = SynchronizedStreamableStore(file) + } + + /** + * Loads the device ID from its file system location. Device IDs are UUIDs which are + * persisted on a per-install basis. This method is thread-safe and multi-process safe. + * + * If no device ID exists then the legacy value stored in [SharedPreferences] will + * be used. If no value is present then a random UUID will be generated and persisted. + */ + fun loadDeviceId(): String? { + return loadDeviceId { + when (val legacyDeviceId = sharedPrefMigrator.loadDeviceId()) { + null -> UUID.randomUUID() + else -> UUID.fromString(legacyDeviceId) + } + } + } + + internal fun loadDeviceId(uuidProvider: () -> UUID): String? { + return try { + // optimistically read device ID without a lock - the majority of the time + // the device ID will already be present so no synchronization is required. + val deviceId = loadDeviceIdInternal() + + if (deviceId?.id != null) { + deviceId.id + } else { + return persistNewDeviceUuid(uuidProvider) + } + } catch (exc: Throwable) { + logger.w("Failed to load device ID", exc) + null + } + } + + /** + * Loads the device ID from the file. + * + * If the file has zero length it can't contain device ID, so reading will be skipped. + */ + private fun loadDeviceIdInternal(): DeviceId? { + if (file.length() > 0) { + try { + return synchronizedStreamableStore.load(DeviceId.Companion::fromReader) + } catch (exc: Throwable) { // catch AssertionError which can be thrown by JsonReader + // on Android 8.0/8.1. see https://issuetracker.google.com/issues/79920590 + logger.w("Failed to load device ID", exc) + } + } + return null + } + + /** + * Write a new Device ID to the file. + */ + private fun persistNewDeviceUuid(uuidProvider: () -> UUID): String? { + return try { + // acquire a FileLock to prevent Clients in different processes writing + // to the same file concurrently + file.outputStream().channel.use { channel -> + persistNewDeviceIdWithLock(channel, uuidProvider) + } + } catch (exc: IOException) { + logger.w("Failed to persist device ID", exc) + null + } + } + + private fun persistNewDeviceIdWithLock( + channel: FileChannel, + uuidProvider: () -> UUID + ): String? { + val lock = waitForFileLock(channel) ?: return null + + return try { + // read the device ID again as it could have changed + // between the last read and when the lock was acquired + val deviceId = loadDeviceIdInternal() + + if (deviceId?.id != null) { + // the device ID changed between the last read + // and acquiring the lock, so return the generated value + deviceId.id + } else { + // generate a new device ID and persist it + val newId = DeviceId(uuidProvider().toString()) + synchronizedStreamableStore.persist(newId) + newId.id + } + } finally { + lock.release() + } + } + + /** + * Attempt to acquire a file lock. If [OverlappingFileLockException] is thrown + * then the method will wait for 50ms then try again, for a maximum of 10 attempts. + */ + private fun waitForFileLock(channel: FileChannel): FileLock? { + repeat(MAX_FILE_LOCK_ATTEMPTS) { + try { + return channel.tryLock() + } catch (exc: OverlappingFileLockException) { + Thread.sleep(FILE_LOCK_WAIT_MS) + } + } + return null + } + + companion object { + private const val MAX_FILE_LOCK_ATTEMPTS = 20 + private const val FILE_LOCK_WAIT_MS = 25L + } +} + +/** + * Serializes and deserializes the device ID to/from JSON. + */ +private class DeviceId(val id: String?) : JsonStream.Streamable { + + override fun toStream(stream: JsonStream) { + with(stream) { + beginObject() + name(KEY_ID) + value(id) + endObject() + } + } + + companion object : JsonReadable { + private const val KEY_ID = "id" + + override fun fromReader(reader: JsonReader): DeviceId { + var id: String? = null + with(reader) { + beginObject() + if (hasNext() && KEY_ID == nextName()) { + id = nextString() + } + } + return DeviceId(id) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/DeviceWithState.kt b/app/src/main/java/com/bugsnag/android/DeviceWithState.kt new file mode 100644 index 0000000000..2dc2d5ab05 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DeviceWithState.kt @@ -0,0 +1,48 @@ +package com.bugsnag.android + +import java.util.Date + +/** + * Stateful information set by the notifier about the device on which the event occurred can be + * found on this class. These values can be accessed and amended if necessary. + */ +class DeviceWithState internal constructor( + buildInfo: DeviceBuildInfo, + jailbroken: Boolean?, + id: String?, + locale: String?, + totalMemory: Long?, + runtimeVersions: MutableMap, + + /** + * The number of free bytes of storage available on the device + */ + var freeDisk: Long?, + + /** + * The number of free bytes of memory available on the device + */ + var freeMemory: Long?, + + /** + * The orientation of the device when the event occurred: either portrait or landscape + */ + var orientation: String?, + + /** + * The timestamp on the device when the event occurred + */ + var time: Date? +) : Device(buildInfo, buildInfo.cpuAbis, jailbroken, id, locale, totalMemory, runtimeVersions) { + + override fun serializeFields(writer: JsonStream) { + super.serializeFields(writer) + writer.name("freeDisk").value(freeDisk) + writer.name("freeMemory").value(freeMemory) + writer.name("orientation").value(orientation) + + if (time != null) { + writer.name("time").value(DateUtils.toIso8601(time!!)) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/EndpointConfiguration.kt b/app/src/main/java/com/bugsnag/android/EndpointConfiguration.kt new file mode 100644 index 0000000000..5d19fded17 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/EndpointConfiguration.kt @@ -0,0 +1,19 @@ +package com.bugsnag.android + +/** + * Set the endpoints to send data to. By default we'll send error reports to + * https://notify.bugsnag.com, and sessions to https://sessions.bugsnag.com, but you can + * override this if you are using Bugsnag Enterprise to point to your own Bugsnag endpoints. + */ +class EndpointConfiguration( + + /** + * Configures the endpoint to which events should be sent + */ + val notify: String = "https://notify.bugsnag.com", + + /** + * Configures the endpoint to which sessions should be sent + */ + val sessions: String = "https://sessions.bugsnag.com" +) diff --git a/app/src/main/java/com/bugsnag/android/Error.java b/app/src/main/java/com/bugsnag/android/Error.java new file mode 100644 index 0000000000..8af63d17da --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Error.java @@ -0,0 +1,101 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + + +/** + * An Error represents information extracted from a {@link Throwable}. + */ +@SuppressWarnings("ConstantConditions") +public class Error implements JsonStream.Streamable { + + private final ErrorInternal impl; + private final Logger logger; + + Error(@NonNull ErrorInternal impl, + @NonNull Logger logger) { + this.impl = impl; + this.logger = logger; + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to error." + property + ", ignoring"); + } + + /** + * Sets the fully-qualified class name of the {@link Throwable} + */ + public void setErrorClass(@NonNull String errorClass) { + if (errorClass != null) { + impl.setErrorClass(errorClass); + } else { + logNull("errorClass"); + } + } + + /** + * Gets the fully-qualified class name of the {@link Throwable} + */ + @NonNull + public String getErrorClass() { + return impl.getErrorClass(); + } + + /** + * The message string from the {@link Throwable} + */ + public void setErrorMessage(@Nullable String errorMessage) { + impl.setErrorMessage(errorMessage); + } + + /** + * The message string from the {@link Throwable} + */ + @Nullable + public String getErrorMessage() { + return impl.getErrorMessage(); + } + + /** + * Sets the type of error based on the originating platform (intended for internal use only) + */ + public void setType(@NonNull ErrorType type) { + if (type != null) { + impl.setType(type); + } else { + logNull("type"); + } + } + + /** + * Sets the type of error based on the originating platform (intended for internal use only) + */ + @NonNull + public ErrorType getType() { + return impl.getType(); + } + + /** + * Gets a representation of the stacktrace + */ + @NonNull + public List getStacktrace() { + return impl.getStacktrace(); + } + + @Override + public void toStream(@NonNull JsonStream stream) throws IOException { + impl.toStream(stream); + } + + static List createError(@NonNull Throwable exc, + @NonNull Collection projectPackages, + @NonNull Logger logger) { + return ErrorInternal.Companion.createError(exc, projectPackages, logger); + } +} diff --git a/app/src/main/java/com/bugsnag/android/ErrorInternal.kt b/app/src/main/java/com/bugsnag/android/ErrorInternal.kt new file mode 100644 index 0000000000..6fcfa81800 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ErrorInternal.kt @@ -0,0 +1,36 @@ +package com.bugsnag.android + +internal class ErrorInternal @JvmOverloads internal constructor( + var errorClass: String, + var errorMessage: String?, + stacktrace: Stacktrace, + var type: ErrorType = ErrorType.ANDROID +) : JsonStream.Streamable { + + val stacktrace: List = stacktrace.trace + + internal companion object { + fun createError(exc: Throwable, projectPackages: Collection, logger: Logger): MutableList { + val errors = mutableListOf() + + var currentEx: Throwable? = exc + while (currentEx != null) { + // Somehow it's possible for stackTrace to be null in rare cases + val stacktrace = currentEx.stackTrace ?: arrayOf() + val trace = Stacktrace.stacktraceFromJavaTrace(stacktrace, projectPackages, logger) + errors.add(ErrorInternal(currentEx.javaClass.name, currentEx.localizedMessage, trace)) + currentEx = currentEx.cause + } + return errors.map { Error(it, logger) }.toMutableList() + } + } + + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("errorClass").value(errorClass) + writer.name("message").value(errorMessage) + writer.name("type").value(type.desc) + writer.name("stacktrace").value(stacktrace) + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/ErrorType.kt b/app/src/main/java/com/bugsnag/android/ErrorType.kt new file mode 100644 index 0000000000..b569b8a807 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ErrorType.kt @@ -0,0 +1,22 @@ +package com.bugsnag.android + +/** + * Represents the type of error captured + */ +enum class ErrorType(internal val desc: String) { + + /** + * An error captured from Android's JVM layer + */ + ANDROID("android"), + + /** + * An error captured from JavaScript + */ + REACTNATIVEJS("reactnativejs"), + + /** + * An error captured from Android's C layer + */ + C("c") +} diff --git a/app/src/main/java/com/bugsnag/android/ErrorTypes.kt b/app/src/main/java/com/bugsnag/android/ErrorTypes.kt new file mode 100644 index 0000000000..03b75e7e87 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ErrorTypes.kt @@ -0,0 +1,36 @@ +package com.bugsnag.android + +class ErrorTypes( + + /** + * Sets whether [ANRs](https://developer.android.com/topic/performance/vitals/anr) + * should be reported to Bugsnag. + * + * If you wish to disable ANR detection, you should set this property to false. + */ + var anrs: Boolean = true, + + /** + * Determines whether NDK crashes such as signals and exceptions should be reported by bugsnag. + * + * This flag is true by default. + */ + var ndkCrashes: Boolean = true, + + /** + * Sets whether Bugsnag should automatically capture and report unhandled errors. + * By default, this value is true. + */ + var unhandledExceptions: Boolean = true, + + /** + * Sets whether Bugsnag should automatically capture and report unhandled promise rejections. + * This only applies to React Native apps. + * By default, this value is true. + */ + var unhandledRejections: Boolean = true +) { + internal constructor(detectErrors: Boolean) : this(detectErrors, detectErrors, detectErrors, detectErrors) + + internal fun copy() = ErrorTypes(anrs, ndkCrashes, unhandledExceptions, unhandledRejections) +} diff --git a/app/src/main/java/com/bugsnag/android/Event.java b/app/src/main/java/com/bugsnag/android/Event.java new file mode 100644 index 0000000000..1f43ca9869 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Event.java @@ -0,0 +1,345 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * An Event object represents a Throwable captured by Bugsnag and is available as a parameter on + * an {@link OnErrorCallback}, where individual properties can be mutated before an error report is + * sent to Bugsnag's API. + */ +@SuppressWarnings("ConstantConditions") +public class Event implements JsonStream.Streamable, MetadataAware, UserAware { + + private final EventInternal impl; + private final Logger logger; + + Event(@Nullable Throwable originalError, + @NonNull ImmutableConfig config, + @NonNull SeverityReason severityReason, + @NonNull Logger logger) { + this(originalError, config, severityReason, new Metadata(), logger); + } + + Event(@Nullable Throwable originalError, + @NonNull ImmutableConfig config, + @NonNull SeverityReason severityReason, + @NonNull Metadata metadata, + @NonNull Logger logger) { + this(new EventInternal(originalError, config, severityReason, metadata), logger); + } + + Event(@NonNull EventInternal impl, @NonNull Logger logger) { + this.impl = impl; + this.logger = logger; + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to config." + property + ", ignoring"); + } + + /** + * The Throwable object that caused the event in your application. + * + * Manipulating this field does not affect the error information reported to the + * Bugsnag dashboard. Use {@link Event#getErrors()} to access and amend the representation of + * the error that will be sent. + */ + @Nullable + public Throwable getOriginalError() { + return impl.getOriginalError(); + } + + /** + * Information extracted from the {@link Throwable} that caused the event can be found in this + * field. The list contains at least one {@link Error} that represents the thrown object + * with subsequent elements in the list populated from {@link Throwable#getCause()}. + * + * A reference to the actual {@link Throwable} object that caused the event is available + * through {@link Event#getOriginalError()} ()}. + */ + @NonNull + public List getErrors() { + return impl.getErrors(); + } + + /** + * If thread state is being captured along with the event, this field will contain a + * list of {@link Thread} objects. + */ + @NonNull + public List getThreads() { + return impl.getThreads(); + } + + /** + * A list of breadcrumbs leading up to the event. These values can be accessed and amended + * if necessary. See {@link Breadcrumb} for details of the data available. + */ + @NonNull + public List getBreadcrumbs() { + return impl.getBreadcrumbs(); + } + + /** + * Information set by the notifier about your app can be found in this field. These values + * can be accessed and amended if necessary. + */ + @NonNull + public AppWithState getApp() { + return impl.getApp(); + } + + /** + * Information set by the notifier about your device can be found in this field. These values + * can be accessed and amended if necessary. + */ + @NonNull + public DeviceWithState getDevice() { + return impl.getDevice(); + } + + /** + * The API key used for events sent to Bugsnag. Even though the API key is set when Bugsnag + * is initialized, you may choose to send certain events to a different Bugsnag project. + */ + public void setApiKey(@NonNull String apiKey) { + if (apiKey != null) { + impl.setApiKey(apiKey); + } else { + logNull("apiKey"); + } + } + + /** + * The API key used for events sent to Bugsnag. Even though the API key is set when Bugsnag + * is initialized, you may choose to send certain events to a different Bugsnag project. + */ + @NonNull + public String getApiKey() { + return impl.getApiKey(); + } + + /** + * The severity of the event. By default, unhandled exceptions will be {@link Severity#ERROR} + * and handled exceptions sent with {@link Bugsnag#notify} {@link Severity#WARNING}. + */ + public void setSeverity(@NonNull Severity severity) { + if (severity != null) { + impl.setSeverity(severity); + } else { + logNull("severity"); + } + } + + /** + * The severity of the event. By default, unhandled exceptions will be {@link Severity#ERROR} + * and handled exceptions sent with {@link Bugsnag#notify} {@link Severity#WARNING}. + */ + @NonNull + public Severity getSeverity() { + return impl.getSeverity(); + } + + /** + * Set the grouping hash of the event to override the default grouping on the dashboard. + * All events with the same grouping hash will be grouped together into one error. This is an + * advanced usage of the library and mis-using it will cause your events not to group properly + * in your dashboard. + * + * As the name implies, this option accepts a hash of sorts. + */ + public void setGroupingHash(@Nullable String groupingHash) { + impl.setGroupingHash(groupingHash); + } + + /** + * Set the grouping hash of the event to override the default grouping on the dashboard. + * All events with the same grouping hash will be grouped together into one error. This is an + * advanced usage of the library and mis-using it will cause your events not to group properly + * in your dashboard. + * + * As the name implies, this option accepts a hash of sorts. + */ + @Nullable + public String getGroupingHash() { + return impl.getGroupingHash(); + } + + /** + * Sets the context of the error. The context is a summary what what was occurring in the + * application at the time of the crash, if available, such as the visible activity. + */ + public void setContext(@Nullable String context) { + impl.setContext(context); + } + + /** + * Returns the context of the error. The context is a summary what what was occurring in the + * application at the time of the crash, if available, such as the visible activity. + */ + @Nullable + public String getContext() { + return impl.getContext(); + } + + /** + * Sets the user associated with the event. + */ + @Override + public void setUser(@Nullable String id, @Nullable String email, @Nullable String name) { + impl.setUser(id, email, name); + } + + /** + * Returns the currently set User information. + */ + @Override + @NonNull + public User getUser() { + return impl.getUser(); + } + + /** + * Adds a map of multiple metadata key-value pairs to the specified section. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull Map value) { + if (section != null && value != null) { + impl.addMetadata(section, value); + } else { + logNull("addMetadata"); + } + } + + /** + * Adds the specified key and value in the specified section. The value can be of + * any primitive type or a collection such as a map, set or array. + */ + @Override + public void addMetadata(@NonNull String section, @NonNull String key, @Nullable Object value) { + if (section != null && key != null) { + impl.addMetadata(section, key, value); + } else { + logNull("addMetadata"); + } + } + + /** + * Removes all the data from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section) { + if (section != null) { + impl.clearMetadata(section); + } else { + logNull("clearMetadata"); + } + } + + /** + * Removes data with the specified key from the specified section. + */ + @Override + public void clearMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + impl.clearMetadata(section, key); + } else { + logNull("clearMetadata"); + } + } + + /** + * Returns a map of data in the specified section. + */ + @Override + @Nullable + public Map getMetadata(@NonNull String section) { + if (section != null) { + return impl.getMetadata(section); + } else { + logNull("getMetadata"); + return null; + } + } + + /** + * Returns the value of the specified key in the specified section. + */ + @Override + @Nullable + public Object getMetadata(@NonNull String section, @NonNull String key) { + if (section != null && key != null) { + return impl.getMetadata(section, key); + } else { + logNull("getMetadata"); + return null; + } + } + + @Override + public void toStream(@NonNull JsonStream stream) throws IOException { + impl.toStream(stream); + } + + /** + * Whether the event was a crash (i.e. unhandled) or handled error in which the system + * continued running. + * + * Unhandled errors count towards your stability score. If you don't want certain errors + * to count towards your stability score, you can alter this property through an + * {@link OnErrorCallback}. + */ + public boolean isUnhandled() { + return impl.getUnhandled(); + } + + /** + * Whether the event was a crash (i.e. unhandled) or handled error in which the system + * continued running. + * + * Unhandled errors count towards your stability score. If you don't want certain errors + * to count towards your stability score, you can alter this property through an + * {@link OnErrorCallback}. + */ + public void setUnhandled(boolean unhandled) { + impl.setUnhandled(unhandled); + } + + protected boolean shouldDiscardClass() { + return impl.shouldDiscardClass(); + } + + protected void updateSeverityInternal(@NonNull Severity severity) { + impl.updateSeverityInternal(severity); + } + + void setApp(@NonNull AppWithState app) { + impl.setApp(app); + } + + void setDevice(@NonNull DeviceWithState device) { + impl.setDevice(device); + } + + void setBreadcrumbs(@NonNull List breadcrumbs) { + impl.setBreadcrumbs(breadcrumbs); + } + + @Nullable + Session getSession() { + return impl.session; + } + + void setSession(@Nullable Session session) { + impl.session = session; + } + + EventInternal getImpl() { + return impl; + } +} diff --git a/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt b/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt new file mode 100644 index 0000000000..1dce6f8a1f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/EventFilenameInfo.kt @@ -0,0 +1,152 @@ +package com.bugsnag.android + +import java.io.File +import java.util.Locale +import java.util.UUID + +/** + * Represents important information about an event which is encoded/decoded from a filename. + * Currently the following information is encoded: + * + * apiKey - as a user can decide to override the value on an Event + * uuid - to disambiguate stored error reports + * timestamp - to sort error reports by time of capture + * suffix - used to encode whether the app crashed on launch, or the report is not a JVM error + * errorTypes - a comma delimited string which contains the stackframe types in the error + */ +internal data class EventFilenameInfo( + val apiKey: String, + val uuid: String, + val timestamp: Long, + val suffix: String, + val errorTypes: Set +) { + + /** + * Generates a filename for the Event in the format + * "[timestamp]_[apiKey]_[errorTypes]_[UUID]_[startupcrash|not-jvm].json" + */ + fun encode(): String { + return String.format( + Locale.US, + "%d_%s_%s_%s_%s.json", + timestamp, + apiKey, + serializeErrorTypeHeader(errorTypes), + uuid, + suffix + ) + } + + fun isLaunchCrashReport(): Boolean = suffix == STARTUP_CRASH + + internal companion object { + private const val STARTUP_CRASH = "startupcrash" + private const val NON_JVM_CRASH = "not-jvm" + + @JvmOverloads + fun fromEvent( + obj: Any, + uuid: String = UUID.randomUUID().toString(), + apiKey: String?, + timestamp: Long = System.currentTimeMillis(), + config: ImmutableConfig, + isLaunching: Boolean? = null + ): EventFilenameInfo { + val sanitizedApiKey = when { + obj is Event -> obj.apiKey + apiKey.isNullOrEmpty() -> config.apiKey + else -> apiKey + } + + return EventFilenameInfo( + sanitizedApiKey, + uuid, + timestamp, + findSuffixForEvent(obj, isLaunching), + findErrorTypesForEvent(obj) + ) + } + + /** + * Reads event information from a filename. + */ + fun fromFile(file: File, config: ImmutableConfig): EventFilenameInfo { + return EventFilenameInfo( + findApiKeyInFilename(file, config), + "", // ignore UUID field when reading from file as unused + -1, // ignore timestamp when reading from file as unused + findSuffixInFilename(file), + findErrorTypesInFilename(file) + ) + } + + /** + * Retrieves the api key encoded in the filename, or an empty string if this information + * is not encoded for the given event + */ + private fun findApiKeyInFilename(file: File, config: ImmutableConfig): String { + val name = file.name.removeSuffix("_$STARTUP_CRASH.json") + val start = name.indexOf("_") + 1 + val end = name.indexOf("_", start) + val apiKey = if (start == 0 || end == -1 || end <= start) { + null + } else { + name.substring(start, end) + } + return apiKey ?: config.apiKey + } + + /** + * Retrieves the error types encoded in the filename, or an empty string if this + * information is not encoded for the given event + */ + private fun findErrorTypesInFilename(eventFile: File): Set { + val name = eventFile.name + val end = name.lastIndexOf("_", name.lastIndexOf("_") - 1) + val start = name.lastIndexOf("_", end - 1) + 1 + + if (start < end) { + val encodedValues: List = name.substring(start, end).split(",") + return ErrorType.values().filter { + encodedValues.contains(it.desc) + }.toSet() + } + return emptySet() + } + + /** + * Retrieves the error types encoded in the filename, or an empty string if this + * information is not encoded for the given event + */ + private fun findSuffixInFilename(eventFile: File): String { + val name = eventFile.nameWithoutExtension + val suffix = name.substring(name.lastIndexOf("_") + 1) + return when (suffix) { + STARTUP_CRASH, NON_JVM_CRASH -> suffix + else -> "" + } + } + + /** + * Retrieves the error types for the given event + */ + private fun findErrorTypesForEvent(obj: Any): Set { + return when (obj) { + is Event -> obj.impl.getErrorTypesFromStackframes() + else -> setOf(ErrorType.C) + } + } + + /** + * Calculates the suffix for the given event + */ + private fun findSuffixForEvent(obj: Any, launching: Boolean?): String { + return when { + obj is Event && obj.app.isLaunching == true -> STARTUP_CRASH + launching == true -> STARTUP_CRASH + else -> "" + } + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/EventInternal.kt b/app/src/main/java/com/bugsnag/android/EventInternal.kt new file mode 100644 index 0000000000..06d23aa63f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/EventInternal.kt @@ -0,0 +1,160 @@ +package com.bugsnag.android + +import java.io.IOException + +internal class EventInternal @JvmOverloads internal constructor( + val originalError: Throwable? = null, + config: ImmutableConfig, + private var severityReason: SeverityReason, + data: Metadata = Metadata() +) : JsonStream.Streamable, MetadataAware, UserAware { + + val metadata: Metadata = data.copy() + private val discardClasses: Set = config.discardClasses.toSet() + private val projectPackages = config.projectPackages + + @JvmField + internal var session: Session? = null + + var severity: Severity + get() = severityReason.currentSeverity + set(value) { + severityReason.currentSeverity = value + } + + var apiKey: String = config.apiKey + lateinit var app: AppWithState + lateinit var device: DeviceWithState + var breadcrumbs: MutableList = mutableListOf() + var unhandled: Boolean + get() = severityReason.unhandled + set(value) { + severityReason.unhandled = value + } + val unhandledOverridden: Boolean + get() = severityReason.unhandledOverridden + + val originalUnhandled: Boolean + get() = severityReason.originalUnhandled + + var errors: MutableList = when (originalError) { + null -> mutableListOf() + else -> Error.createError(originalError, config.projectPackages, config.logger) + } + + var threads: MutableList = ThreadState(originalError, unhandled, config).threads + var groupingHash: String? = null + var context: String? = null + + /** + * @return user information associated with this Event + */ + internal var _user = User(null, null, null) + + protected fun shouldDiscardClass(): Boolean { + return when { + errors.isEmpty() -> true + else -> errors.any { discardClasses.contains(it.errorClass) } + } + } + + protected fun isAnr(event: Event): Boolean { + val errors = event.errors + var errorClass: String? = null + if (errors.isNotEmpty()) { + val error = errors[0] + errorClass = error.errorClass + } + return "ANR" == errorClass + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + // Write error basics + writer.beginObject() + writer.name("context").value(context) + writer.name("metaData").value(metadata) + + writer.name("severity").value(severity) + writer.name("severityReason").value(severityReason) + writer.name("unhandled").value(severityReason.unhandled) + + // Write exception info + writer.name("exceptions") + writer.beginArray() + errors.forEach { writer.value(it) } + writer.endArray() + + // Write project packages + writer.name("projectPackages") + writer.beginArray() + projectPackages.forEach { writer.value(it) } + writer.endArray() + + // Write user info + writer.name("user").value(_user) + + // Write diagnostics + writer.name("app").value(app) + writer.name("device").value(device) + writer.name("breadcrumbs").value(breadcrumbs) + writer.name("groupingHash").value(groupingHash) + + writer.name("threads") + writer.beginArray() + threads.forEach { writer.value(it) } + writer.endArray() + + if (session != null) { + val copy = Session.copySession(session) + writer.name("session").beginObject() + writer.name("id").value(copy.id) + writer.name("startedAt").value(DateUtils.toIso8601(copy.startedAt)) + writer.name("events").beginObject() + writer.name("handled").value(copy.handledCount.toLong()) + writer.name("unhandled").value(copy.unhandledCount.toLong()) + writer.endObject() + writer.endObject() + } + + writer.endObject() + } + + internal fun getErrorTypesFromStackframes(): Set { + val errorTypes = errors.mapNotNull(Error::getType).toSet() + val frameOverrideTypes = errors + .map { it.stacktrace } + .flatMap { it.mapNotNull(Stackframe::type) } + return errorTypes.plus(frameOverrideTypes) + } + + protected fun updateSeverityInternal(severity: Severity) { + severityReason = SeverityReason.newInstance( + severityReason.severityReasonType, + severity, + severityReason.attributeValue + ) + this.severity = severity + } + + fun getSeverityReasonType(): String = severityReason.severityReasonType + + override fun setUser(id: String?, email: String?, name: String?) { + _user = User(id, email, name) + } + + override fun getUser() = _user + + override fun addMetadata(section: String, value: Map) = metadata.addMetadata(section, value) + + override fun addMetadata(section: String, key: String, value: Any?) = + metadata.addMetadata(section, key, value) + + override fun clearMetadata(section: String) = metadata.clearMetadata(section) + + override fun clearMetadata(section: String, key: String) = metadata.clearMetadata(section, key) + + override fun getMetadata(section: String) = metadata.getMetadata(section) + + override fun getMetadata(section: String, key: String) = metadata.getMetadata(section, key) +} diff --git a/app/src/main/java/com/bugsnag/android/EventPayload.kt b/app/src/main/java/com/bugsnag/android/EventPayload.kt new file mode 100644 index 0000000000..9294d9ac0e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/EventPayload.kt @@ -0,0 +1,49 @@ +package com.bugsnag.android + +import java.io.File +import java.io.IOException + +/** + * An error report payload. + * + * This payload contains an error report and identifies the source application + * using your API key. + */ +class EventPayload @JvmOverloads internal constructor( + var apiKey: String?, + val event: Event? = null, + internal val eventFile: File? = null, + notifier: Notifier, + private val config: ImmutableConfig +) : JsonStream.Streamable { + + internal val notifier = Notifier(notifier.name, notifier.version, notifier.url).apply { + dependencies = notifier.dependencies.toMutableList() + } + + internal fun getErrorTypes(): Set { + return when { + event != null -> event.impl.getErrorTypesFromStackframes() + eventFile != null -> EventFilenameInfo.fromFile(eventFile, config).errorTypes + else -> emptySet() + } + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("apiKey").value(apiKey) + writer.name("payloadVersion").value("4.0") + writer.name("notifier").value(notifier) + writer.name("events").beginArray() + + when { + event != null -> writer.value(event) + eventFile != null -> writer.value(eventFile) + else -> Unit + } + + writer.endArray() + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/EventStore.java b/app/src/main/java/com/bugsnag/android/EventStore.java new file mode 100644 index 0000000000..4e5d235e37 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/EventStore.java @@ -0,0 +1,213 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Store and flush Event reports which couldn't be sent immediately due to + * lack of network connectivity. + */ +class EventStore extends FileStore { + + private static final long LAUNCH_CRASH_TIMEOUT_MS = 2000; + + private final ImmutableConfig config; + private final Delegate delegate; + private final Notifier notifier; + private final BackgroundTaskService bgTaskSevice; + final Logger logger; + + static final Comparator EVENT_COMPARATOR = new Comparator() { + @Override + public int compare(File lhs, File rhs) { + if (lhs == null && rhs == null) { + return 0; + } + if (lhs == null) { + return 1; + } + if (rhs == null) { + return -1; + } + return lhs.compareTo(rhs); + } + }; + + EventStore(@NonNull ImmutableConfig config, + @NonNull Logger logger, + Notifier notifier, + BackgroundTaskService bgTaskSevice, + Delegate delegate) { + super(new File(config.getPersistenceDirectory(), "bugsnag-errors"), + config.getMaxPersistedEvents(), + EVENT_COMPARATOR, + logger, + delegate); + this.config = config; + this.logger = logger; + this.delegate = delegate; + this.notifier = notifier; + this.bgTaskSevice = bgTaskSevice; + } + + /** + * Flush startup crashes synchronously on the main thread + */ + void flushOnLaunch() { + if (!config.getSendLaunchCrashesSynchronously()) { + return; + } + Future future = null; + try { + future = bgTaskSevice.submitTask(TaskType.ERROR_REQUEST, new Runnable() { + @Override + public void run() { + flushLaunchCrashReport(); + } + }); + } catch (RejectedExecutionException exc) { + logger.d("Failed to flush launch crash reports, continuing.", exc); + } + + try { + if (future != null) { + future.get(LAUNCH_CRASH_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + } catch (InterruptedException | ExecutionException | TimeoutException exc) { + logger.d("Failed to send launch crash reports within 2s timeout, continuing.", exc); + } + } + + void flushLaunchCrashReport() { + List storedFiles = findStoredFiles(); + File launchCrashReport = findLaunchCrashReport(storedFiles); + + // cancel non-launch crash reports + if (launchCrashReport != null) { + storedFiles.remove(launchCrashReport); + } + cancelQueuedFiles(storedFiles); + + if (launchCrashReport != null) { + logger.i("Attempting to send the most recent launch crash report"); + flushReports(Collections.singletonList(launchCrashReport)); + logger.i("Continuing with Bugsnag initialisation"); + } else { + logger.d("No startupcrash events to flush to Bugsnag."); + } + } + + @Nullable + File findLaunchCrashReport(Collection storedFiles) { + List launchCrashes = new ArrayList<>(); + + for (File file : storedFiles) { + EventFilenameInfo filenameInfo = EventFilenameInfo.Companion.fromFile(file, config); + if (filenameInfo.isLaunchCrashReport()) { + launchCrashes.add(file); + } + } + + // sort to get most recent timestamp + Collections.sort(launchCrashes, EVENT_COMPARATOR); + return launchCrashes.isEmpty() ? null : launchCrashes.get(launchCrashes.size() - 1); + } + + /** + * Flush any on-disk errors to Bugsnag + */ + void flushAsync() { + try { + bgTaskSevice.submitTask(TaskType.ERROR_REQUEST, new Runnable() { + @Override + public void run() { + List storedFiles = findStoredFiles(); + if (storedFiles.isEmpty()) { + logger.d("No regular events to flush to Bugsnag."); + } + flushReports(storedFiles); + } + }); + } catch (RejectedExecutionException exception) { + logger.w("Failed to flush all on-disk errors, retaining unsent errors for later."); + } + } + + void flushReports(Collection storedReports) { + if (!storedReports.isEmpty()) { + logger.i(String.format(Locale.US, + "Sending %d saved error(s) to Bugsnag", storedReports.size())); + + for (File eventFile : storedReports) { + flushEventFile(eventFile); + } + } + } + + private void flushEventFile(File eventFile) { + try { + EventFilenameInfo eventInfo = EventFilenameInfo.Companion.fromFile(eventFile, config); + String apiKey = eventInfo.getApiKey(); + EventPayload payload = new EventPayload(apiKey, null, eventFile, notifier, config); + DeliveryParams deliveryParams = config.getErrorApiDeliveryParams(payload); + Delivery delivery = config.getDelivery(); + DeliveryStatus deliveryStatus = delivery.deliver(payload, deliveryParams); + + switch (deliveryStatus) { + case DELIVERED: + deleteStoredFiles(Collections.singleton(eventFile)); + logger.i("Deleting sent error file " + eventFile.getName()); + break; + case UNDELIVERED: + cancelQueuedFiles(Collections.singleton(eventFile)); + logger.w("Could not send previously saved error(s)" + + " to Bugsnag, will try again later"); + break; + case FAILURE: + Exception exc = new RuntimeException("Failed to deliver event payload"); + handleEventFlushFailure(exc, eventFile); + break; + default: + break; + } + } catch (Exception exception) { + handleEventFlushFailure(exception, eventFile); + } + } + + private void handleEventFlushFailure(Exception exc, File eventFile) { + if (delegate != null) { + delegate.onErrorIOFailure(exc, eventFile, "Crash Report Deserialization"); + } + deleteStoredFiles(Collections.singleton(eventFile)); + } + + @NonNull + @Override + String getFilename(Object object) { + EventFilenameInfo eventInfo + = EventFilenameInfo.Companion.fromEvent(object, null, config); + String encodedInfo = eventInfo.encode(); + return String.format(Locale.US, "%s", encodedInfo); + } + + String getNdkFilename(Object object, String apiKey) { + EventFilenameInfo eventInfo + = EventFilenameInfo.Companion.fromEvent(object, apiKey, config); + String encodedInfo = eventInfo.encode(); + return String.format(Locale.US, "%s", encodedInfo); + } +} diff --git a/app/src/main/java/com/bugsnag/android/ExceptionHandler.java b/app/src/main/java/com/bugsnag/android/ExceptionHandler.java new file mode 100644 index 0000000000..a10ee3f263 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ExceptionHandler.java @@ -0,0 +1,67 @@ +package com.bugsnag.android; + +import android.os.StrictMode; +import androidx.annotation.NonNull; + +import java.lang.Thread; +import java.lang.Thread.UncaughtExceptionHandler; + +/** + * Provides automatic notification hooks for unhandled exceptions. + */ +class ExceptionHandler implements UncaughtExceptionHandler { + + private static final String STRICT_MODE_TAB = "StrictMode"; + private static final String STRICT_MODE_KEY = "Violation"; + + private final UncaughtExceptionHandler originalHandler; + private final StrictModeHandler strictModeHandler = new StrictModeHandler(); + private final Client client; + private final Logger logger; + + ExceptionHandler(Client client, Logger logger) { + this.client = client; + this.logger = logger; + this.originalHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + } + + @Override + public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { + boolean strictModeThrowable = strictModeHandler.isStrictModeThrowable(throwable); + + // Notify any subscribed clients of the uncaught exception + Metadata metadata = new Metadata(); + String violationDesc = null; + + if (strictModeThrowable) { // add strictmode policy violation to metadata + violationDesc = strictModeHandler.getViolationDescription(throwable.getMessage()); + metadata = new Metadata(); + metadata.addMetadata(STRICT_MODE_TAB, STRICT_MODE_KEY, violationDesc); + } + + String severityReason = strictModeThrowable + ? SeverityReason.REASON_STRICT_MODE : SeverityReason.REASON_UNHANDLED_EXCEPTION; + + if (strictModeThrowable) { // writes to disk on main thread + StrictMode.ThreadPolicy originalThreadPolicy = StrictMode.getThreadPolicy(); + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.LAX); + + client.notifyUnhandledException(throwable, + metadata, severityReason, violationDesc); + + StrictMode.setThreadPolicy(originalThreadPolicy); + } else { + client.notifyUnhandledException(throwable, + metadata, severityReason, null); + } + + // Pass exception on to original exception handler + if (originalHandler != null) { + originalHandler.uncaughtException(thread, throwable); + } else { + System.err.printf("Exception in thread \"%s\" ", thread.getName()); + logger.w("Exception", throwable); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/FileStore.java b/app/src/main/java/com/bugsnag/android/FileStore.java new file mode 100644 index 0000000000..ee3cdd180e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/FileStore.java @@ -0,0 +1,241 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +abstract class FileStore { + + interface Delegate { + + /** + * Invoked when an error report is not (de)serialized correctly + * + * @param exception the error encountered reading/delivering the file + * @param errorFile file which could not be (de)serialized correctly + * @param context the context used to group the exception + */ + void onErrorIOFailure(Exception exception, File errorFile, String context); + } + + private final File storageDir; + private final int maxStoreCount; + private final Comparator comparator; + + private final Lock lock = new ReentrantLock(); + private final Collection queuedFiles = new ConcurrentSkipListSet<>(); + private final Logger logger; + private final EventStore.Delegate delegate; + + FileStore(@NonNull File storageDir, + int maxStoreCount, + Comparator comparator, + Logger logger, + Delegate delegate) { + this.maxStoreCount = maxStoreCount; + this.comparator = comparator; + this.logger = logger; + this.delegate = delegate; + this.storageDir = storageDir; + isStorageDirValid(storageDir); + } + + /** + * Checks whether the storage directory is a writable directory. If it is not, + * this method will attempt to create the directory. + * + * If the directory could not be created then an error will be logged. + */ + private boolean isStorageDirValid(@NonNull File storageDir) { + try { + if (!storageDir.isDirectory() || !storageDir.canWrite()) { + if (!storageDir.mkdirs()) { + this.logger.e("Could not prepare storage directory at " + + storageDir.getAbsolutePath()); + return false; + } + } + } catch (Exception exception) { + this.logger.e("Could not prepare file storage directory", exception); + return false; + } + return true; + } + + void enqueueContentForDelivery(String content, String filename) { + if (!isStorageDirValid(storageDir)) { + return; + } + discardOldestFileIfNeeded(); + + lock.lock(); + Writer out = null; + String filePath = new File(storageDir, filename).getAbsolutePath(); + try { + FileOutputStream fos = new FileOutputStream(filePath); + out = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8")); + out.write(content); + } catch (Exception exc) { + File eventFile = new File(filePath); + + if (delegate != null) { + delegate.onErrorIOFailure(exc, eventFile, "NDK Crash report copy"); + } + + IOUtils.deleteFile(eventFile, logger); + } finally { + try { + if (out != null) { + out.close(); + } + } catch (Exception exception) { + logger.w(String.format("Failed to close unsent payload writer (%s) ", + filename), exception); + } + lock.unlock(); + } + } + + @Nullable + String write(@NonNull JsonStream.Streamable streamable) { + if (!isStorageDirValid(storageDir)) { + return null; + } + if (maxStoreCount == 0) { + return null; + } + discardOldestFileIfNeeded(); + String filename = new File(storageDir, getFilename(streamable)).getAbsolutePath(); + + JsonStream stream = null; + lock.lock(); + + try { + FileOutputStream fos = new FileOutputStream(filename); + Writer out = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8")); + stream = new JsonStream(out); + stream.value(streamable); + logger.i(String.format("Saved unsent payload to disk (%s) ", filename)); + return filename; + } catch (FileNotFoundException exc) { + logger.w("Ignoring FileNotFoundException - unable to create file", exc); + } catch (Exception exc) { + File eventFile = new File(filename); + + if (delegate != null) { + delegate.onErrorIOFailure(exc, eventFile, "Crash report serialization"); + } + + IOUtils.deleteFile(eventFile, logger); + } finally { + IOUtils.closeQuietly(stream); + lock.unlock(); + } + return null; + } + + void discardOldestFileIfNeeded() { + // Limit number of saved payloads to prevent disk space issues + if (isStorageDirValid(storageDir)) { + File[] listFiles = storageDir.listFiles(); + + if (listFiles == null) { + return; + } + + List files = new ArrayList<>(Arrays.asList(listFiles)); + + if (files.size() >= maxStoreCount) { + // Sort files then delete the first one (oldest timestamp) + Collections.sort(files, comparator); + + for (int k = 0; k < files.size() && files.size() >= maxStoreCount; k++) { + File oldestFile = files.get(k); + + if (!queuedFiles.contains(oldestFile)) { + logger.w(String.format("Discarding oldest error as stored " + + "error limit reached (%s)", oldestFile.getPath())); + deleteStoredFiles(Collections.singleton(oldestFile)); + files.remove(k); + k--; + } + } + } + } + } + + @NonNull + abstract String getFilename(Object object); + + List findStoredFiles() { + lock.lock(); + try { + List files = new ArrayList<>(); + + if (isStorageDirValid(storageDir)) { + File[] values = storageDir.listFiles(); + + if (values != null) { + for (File value : values) { + // delete any tombstoned/empty files, as they contain no useful info + if (value.length() == 0) { + if (!value.delete()) { + value.deleteOnExit(); + } + } else if (value.isFile() && !queuedFiles.contains(value)) { + files.add(value); + } + } + } + } + queuedFiles.addAll(files); + return files; + } finally { + lock.unlock(); + } + } + + void cancelQueuedFiles(Collection files) { + lock.lock(); + try { + if (files != null) { + queuedFiles.removeAll(files); + } + } finally { + lock.unlock(); + } + } + + void deleteStoredFiles(Collection storedFiles) { + lock.lock(); + try { + if (storedFiles != null) { + queuedFiles.removeAll(storedFiles); + + for (File storedFile : storedFiles) { + if (!storedFile.delete()) { + storedFile.deleteOnExit(); + } + } + } + } finally { + lock.unlock(); + } + } + +} diff --git a/app/src/main/java/com/bugsnag/android/ForegroundDetector.java b/app/src/main/java/com/bugsnag/android/ForegroundDetector.java new file mode 100644 index 0000000000..1f534e967f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ForegroundDetector.java @@ -0,0 +1,73 @@ +package com.bugsnag.android; + +import android.app.ActivityManager; +import android.content.Context; +import android.os.Build; +import android.os.Process; +import androidx.annotation.Nullable; + +import java.util.List; + +class ForegroundDetector { + + private final ActivityManager activityManager; + + ForegroundDetector(Context context) { + this.activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + } + + /** + * Determines whether or not the application is in the foreground, by using the process' + * importance as a proxy. + *

+ * In the unlikely event that information about the process cannot be retrieved, this method + * will return null, and the 'inForeground' and 'durationInForeground' values will not be + * serialized in API calls. + * + * @return whether the application is in the foreground or not + */ + @Nullable + Boolean isInForeground() { + try { + ActivityManager.RunningAppProcessInfo info = getProcessInfo(); + + if (info != null) { + return info.importance + <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + } else { + return null; + } + } catch (RuntimeException exc) { + return null; + } + } + + private ActivityManager.RunningAppProcessInfo getProcessInfo() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + ActivityManager.RunningAppProcessInfo info = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(info); + return info; + } else { + return getProcessInfoPreApi16(); + } + } + + @Nullable + private ActivityManager.RunningAppProcessInfo getProcessInfoPreApi16() { + List appProcesses + = activityManager.getRunningAppProcesses(); + + if (appProcesses != null) { + int pid = Process.myPid(); + + for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) { + if (pid == appProcess.pid) { + return appProcess; + } + } + } + return null; + } +} diff --git a/app/src/main/java/com/bugsnag/android/IOUtils.java b/app/src/main/java/com/bugsnag/android/IOUtils.java new file mode 100644 index 0000000000..b3b5a13838 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/IOUtils.java @@ -0,0 +1,55 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.net.HttpURLConnection; +import java.net.URLConnection; + +@SuppressWarnings("checkstyle:AbbreviationAsWordInName") +class IOUtils { + private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; + private static final int EOF = -1; + + static void closeQuietly(@Nullable final Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (@NonNull final Exception ioe) { + // ignore + } + } + + static int copy(@NonNull final Reader input, + @NonNull final Writer output) throws IOException { + char[] buffer = new char[DEFAULT_BUFFER_SIZE]; + long count = 0; + int read; + while (EOF != (read = input.read(buffer))) { + output.write(buffer, 0, read); + count += read; + } + + if (count > Integer.MAX_VALUE) { + return -1; + } + + return (int) count; + } + + static void deleteFile(File file, Logger logger) { + try { + if (!file.delete()) { + file.deleteOnExit(); + } + } catch (Exception ex) { + logger.w("Failed to delete file", ex); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/ImmutableConfig.kt b/app/src/main/java/com/bugsnag/android/ImmutableConfig.kt new file mode 100644 index 0000000000..d4d0753c2e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ImmutableConfig.kt @@ -0,0 +1,151 @@ +package com.bugsnag.android + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import java.io.File + +internal data class ImmutableConfig( + val apiKey: String, + val autoDetectErrors: Boolean, + val enabledErrorTypes: ErrorTypes, + val autoTrackSessions: Boolean, + val sendThreads: ThreadSendPolicy, + val discardClasses: Collection, + val enabledReleaseStages: Collection?, + val projectPackages: Collection, + val enabledBreadcrumbTypes: Set?, + val releaseStage: String?, + val buildUuid: String?, + val appVersion: String?, + val versionCode: Int?, + val appType: String?, + val delivery: Delivery, + val endpoints: EndpointConfiguration, + val persistUser: Boolean, + val launchDurationMillis: Long, + val logger: Logger, + val maxBreadcrumbs: Int, + val maxPersistedEvents: Int, + val maxPersistedSessions: Int, + val persistenceDirectory: File, + val sendLaunchCrashesSynchronously: Boolean +) { + + /** + * Checks if the given release stage should be notified or not + * + * @return true if the release state should be notified else false + */ + @JvmName("shouldNotifyForReleaseStage") + internal fun shouldNotifyForReleaseStage() = + enabledReleaseStages == null || enabledReleaseStages.contains(releaseStage) + + @JvmName("shouldRecordBreadcrumbType") + internal fun shouldRecordBreadcrumbType(type: BreadcrumbType) = + enabledBreadcrumbTypes == null || enabledBreadcrumbTypes.contains(type) + + @JvmName("getErrorApiDeliveryParams") + internal fun getErrorApiDeliveryParams(payload: EventPayload) = + DeliveryParams(endpoints.notify, errorApiHeaders(payload)) + + @JvmName("getSessionApiDeliveryParams") + internal fun getSessionApiDeliveryParams() = + DeliveryParams(endpoints.sessions, sessionApiHeaders(apiKey)) +} + +internal fun convertToImmutableConfig( + config: Configuration, + buildUuid: String? = null +): ImmutableConfig { + val errorTypes = when { + config.autoDetectErrors -> config.enabledErrorTypes.copy() + else -> ErrorTypes(false) + } + + return ImmutableConfig( + apiKey = config.apiKey, + autoDetectErrors = config.autoDetectErrors, + enabledErrorTypes = errorTypes, + autoTrackSessions = config.autoTrackSessions, + sendThreads = config.sendThreads, + discardClasses = config.discardClasses.toSet(), + enabledReleaseStages = config.enabledReleaseStages?.toSet(), + projectPackages = config.projectPackages.toSet(), + releaseStage = config.releaseStage, + buildUuid = buildUuid, + appVersion = config.appVersion, + versionCode = config.versionCode, + appType = config.appType, + delivery = config.delivery, + endpoints = config.endpoints, + persistUser = config.persistUser, + launchDurationMillis = config.launchDurationMillis, + logger = config.logger!!, + maxBreadcrumbs = config.maxBreadcrumbs, + maxPersistedEvents = config.maxPersistedEvents, + maxPersistedSessions = config.maxPersistedSessions, + enabledBreadcrumbTypes = config.enabledBreadcrumbTypes?.toSet(), + persistenceDirectory = config.persistenceDirectory!!, + sendLaunchCrashesSynchronously = config.sendLaunchCrashesSynchronously + ) +} + +internal fun sanitiseConfiguration( + appContext: Context, + configuration: Configuration, + connectivity: Connectivity +): ImmutableConfig { + val packageName = appContext.packageName + val packageManager = appContext.packageManager + val packageInfo = runCatching { packageManager.getPackageInfo(packageName, 0) }.getOrNull() + val appInfo = runCatching { + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + }.getOrNull() + + // populate releaseStage + if (configuration.releaseStage == null) { + configuration.releaseStage = when { + appInfo != null && (appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) -> RELEASE_STAGE_DEVELOPMENT + else -> RELEASE_STAGE_PRODUCTION + } + } + + // if the user has set the releaseStage to production manually, disable logging + if (configuration.logger == null || configuration.logger == DebugLogger) { + val releaseStage = configuration.releaseStage + val loggingEnabled = RELEASE_STAGE_PRODUCTION != releaseStage + + if (loggingEnabled) { + configuration.logger = DebugLogger + } else { + configuration.logger = NoopLogger + } + } + + if (configuration.versionCode == null || configuration.versionCode == 0) { + @Suppress("DEPRECATION") + configuration.versionCode = packageInfo?.versionCode + } + + // Set sensible defaults if project packages not already set + if (configuration.projectPackages.isEmpty()) { + configuration.projectPackages = setOf(packageName) + } + + // populate buildUUID from manifest + val buildUuid = appInfo?.metaData?.getString(ManifestConfigLoader.BUILD_UUID) + + @Suppress("SENSELESS_COMPARISON") + if (configuration.delivery == null) { + configuration.delivery = DefaultDelivery(connectivity, configuration.logger!!) + } + + if (configuration.persistenceDirectory == null) { + configuration.persistenceDirectory = appContext.cacheDir + } + return convertToImmutableConfig(configuration, buildUuid) +} + +internal const val RELEASE_STAGE_DEVELOPMENT = "development" +internal const val RELEASE_STAGE_PRODUCTION = "production" diff --git a/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java b/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java new file mode 100644 index 0000000000..b49c2a7503 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java @@ -0,0 +1,131 @@ +package com.bugsnag.android; + +import static com.bugsnag.android.DeliveryHeadersKt.HEADER_INTERNAL_ERROR; +import static com.bugsnag.android.SeverityReason.REASON_UNHANDLED_EXCEPTION; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.storage.StorageManager; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.IOException; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + +class InternalReportDelegate implements EventStore.Delegate { + + static final String INTERNAL_DIAGNOSTICS_TAB = "BugsnagDiagnostics"; + + final Logger logger; + final ImmutableConfig config; + final StorageManager storageManager; + + final AppDataCollector appDataCollector; + final DeviceDataCollector deviceDataCollector; + final Context appContext; + final SessionTracker sessionTracker; + final Notifier notifier; + final BackgroundTaskService backgroundTaskService; + + InternalReportDelegate(Context context, + Logger logger, + ImmutableConfig immutableConfig, + StorageManager storageManager, + AppDataCollector appDataCollector, + DeviceDataCollector deviceDataCollector, + SessionTracker sessionTracker, + Notifier notifier, + BackgroundTaskService backgroundTaskService) { + this.logger = logger; + this.config = immutableConfig; + this.storageManager = storageManager; + this.appDataCollector = appDataCollector; + this.deviceDataCollector = deviceDataCollector; + this.appContext = context; + this.sessionTracker = sessionTracker; + this.notifier = notifier; + this.backgroundTaskService = backgroundTaskService; + } + + @Override + public void onErrorIOFailure(Exception exc, File errorFile, String context) { + // send an internal error to bugsnag with no cache + SeverityReason severityReason = SeverityReason.newInstance(REASON_UNHANDLED_EXCEPTION); + Event err = new Event(exc, config, severityReason, logger); + err.setContext(context); + + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "canRead", errorFile.canRead()); + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "canWrite", errorFile.canWrite()); + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "exists", errorFile.exists()); + + @SuppressLint("UsableSpace") // storagemanager alternative API requires API 26 + long usableSpace = appContext.getCacheDir().getUsableSpace(); + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "usableSpace", usableSpace); + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "filename", errorFile.getName()); + err.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "fileLength", errorFile.length()); + recordStorageCacheBehavior(err); + reportInternalBugsnagError(err); + } + + void recordStorageCacheBehavior(Event event) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + File cacheDir = appContext.getCacheDir(); + File errDir = new File(cacheDir, "bugsnag-errors"); + + try { + boolean tombstone = storageManager.isCacheBehaviorTombstone(errDir); + boolean group = storageManager.isCacheBehaviorGroup(errDir); + event.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "cacheTombstone", tombstone); + event.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "cacheGroup", group); + } catch (IOException exc) { + logger.w("Failed to record cache behaviour, skipping diagnostics", exc); + } + } + } + + /** + * Reports an event that occurred within the notifier to bugsnag. A lean event report will be + * generated and sent asynchronously with no callbacks, retry attempts, or writing to disk. + * This is intended for internal use only, and reports will not be visible to end-users. + */ + void reportInternalBugsnagError(@NonNull Event event) { + event.setApp(appDataCollector.generateAppWithState()); + event.setDevice(deviceDataCollector.generateDeviceWithState(new Date().getTime())); + + event.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "notifierName", notifier.getName()); + event.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "notifierVersion", notifier.getVersion()); + event.addMetadata(INTERNAL_DIAGNOSTICS_TAB, "apiKey", config.getApiKey()); + + final EventPayload payload = new EventPayload(null, event, notifier, config); + try { + backgroundTaskService.submitTask(TaskType.INTERNAL_REPORT, new Runnable() { + @Override + public void run() { + try { + logger.d("InternalReportDelegate - sending internal event"); + Delivery delivery = config.getDelivery(); + DeliveryParams params = config.getErrorApiDeliveryParams(payload); + + // can only modify headers if DefaultDelivery is in use + if (delivery instanceof DefaultDelivery) { + Map headers = params.getHeaders(); + headers.put(HEADER_INTERNAL_ERROR, "true"); + headers.remove(DeliveryHeadersKt.HEADER_API_KEY); + DefaultDelivery defaultDelivery = (DefaultDelivery) delivery; + defaultDelivery.deliver(params.getEndpoint(), payload, headers); + } + + } catch (Exception exception) { + logger.w("Failed to report internal event to Bugsnag", exception); + } + } + }); + } catch (RejectedExecutionException ignored) { + // drop internal report + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Intrinsics.java b/app/src/main/java/com/bugsnag/android/Intrinsics.java new file mode 100644 index 0000000000..70e18f2dbe --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Intrinsics.java @@ -0,0 +1,8 @@ +package com.bugsnag.android; + +class Intrinsics { + + static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } +} diff --git a/app/src/main/java/com/bugsnag/android/JsonReadable.kt b/app/src/main/java/com/bugsnag/android/JsonReadable.kt new file mode 100644 index 0000000000..dc68f3e82f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/JsonReadable.kt @@ -0,0 +1,14 @@ +package com.bugsnag.android + +import android.util.JsonReader + +/** + * Classes which implement this interface are capable of deserializing a JSON input. + */ +internal interface JsonReadable { + + /** + * Constructs an object from a JSON input. + */ + fun fromReader(reader: JsonReader): T +} diff --git a/app/src/main/java/com/bugsnag/android/JsonScope.java b/app/src/main/java/com/bugsnag/android/JsonScope.java new file mode 100644 index 0000000000..2dc37d8a37 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/JsonScope.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bugsnag.android; + +// last retrieved from gson-parent-2.8.5 on 17/01/2019 +// https://github.com/google/gson/tree/gson-parent-2.8.5/gson/src/main/java/com/google/gson/stream + +/** + * Lexical scoping elements within a JSON reader or writer. + * + * @author Jesse Wilson + * @since 1.6 + */ +@SuppressWarnings("all") +final class JsonScope { + + /** + * An array with no elements requires no separators or newlines before + * it is closed. + */ + static final int EMPTY_ARRAY = 1; + + /** + * A array with at least one value requires a comma and newline before + * the next element. + */ + static final int NONEMPTY_ARRAY = 2; + + /** + * An object with no name/value pairs requires no separators or newlines + * before it is closed. + */ + static final int EMPTY_OBJECT = 3; + + /** + * An object whose most recent element is a key. The next element must + * be a value. + */ + static final int DANGLING_NAME = 4; + + /** + * An object with at least one name/value pair requires a comma and + * newline before the next element. + */ + static final int NONEMPTY_OBJECT = 5; + + /** + * No object or array has been started. + */ + static final int EMPTY_DOCUMENT = 6; + + /** + * A document with at an array or object. + */ + static final int NONEMPTY_DOCUMENT = 7; + + /** + * A document that's been closed and cannot be accessed. + */ + static final int CLOSED = 8; +} diff --git a/app/src/main/java/com/bugsnag/android/JsonStream.java b/app/src/main/java/com/bugsnag/android/JsonStream.java new file mode 100644 index 0000000000..1ef62a2a88 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/JsonStream.java @@ -0,0 +1,86 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Writer; + +public class JsonStream extends JsonWriter { + + private final ObjectJsonStreamer objectJsonStreamer; + + public interface Streamable { + void toStream(@NonNull JsonStream stream) throws IOException; + } + + private final Writer out; + + /** + * Constructs a JSONStream + * + * @param out the writer + */ + public JsonStream(@NonNull Writer out) { + super(out); + setSerializeNulls(false); + this.out = out; + objectJsonStreamer = new ObjectJsonStreamer(); + } + + // Allow chaining name().value() + @NonNull + public JsonStream name(@Nullable String name) throws IOException { + super.name(name); + return this; + } + + /** + * Serialises an arbitrary object as JSON, handling primitive types as well as + * Collections, Maps, and arrays. + */ + public void value(@Nullable Object object, boolean shouldRedactKeys) throws IOException { + if (object instanceof Streamable) { + ((Streamable) object).toStream(this); + } else { + objectJsonStreamer.objectToStream(object, this, shouldRedactKeys); + } + } + + /** + * Serialises an arbitrary object as JSON, handling primitive types as well as + * Collections, Maps, and arrays. + */ + public void value(@Nullable Object object) throws IOException { + value(object, false); + } + + /** + * Writes a File (its content) into the stream + */ + public void value(@NonNull File file) throws IOException { + if (file == null || file.length() <= 0) { + return; + } + + super.flush(); + beforeValue(); // add comma if in array + + // Copy the file contents onto the stream + Reader input = null; + try { + FileInputStream fis = new FileInputStream(file); + input = new BufferedReader(new InputStreamReader(fis, "UTF-8")); + IOUtils.copy(input, out); + } finally { + IOUtils.closeQuietly(input); + } + + out.flush(); + } +} diff --git a/app/src/main/java/com/bugsnag/android/JsonWriter.java b/app/src/main/java/com/bugsnag/android/JsonWriter.java new file mode 100644 index 0000000000..3e352b713d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/JsonWriter.java @@ -0,0 +1,663 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bugsnag.android; + +// last retrieved from gson-parent-2.8.5 on 17/01/2019 +// https://github.com/google/gson/tree/gson-parent-2.8.5/gson/src/main/java/com/google/gson/stream + +import static com.bugsnag.android.JsonScope.DANGLING_NAME; +import static com.bugsnag.android.JsonScope.EMPTY_ARRAY; +import static com.bugsnag.android.JsonScope.EMPTY_DOCUMENT; +import static com.bugsnag.android.JsonScope.EMPTY_OBJECT; +import static com.bugsnag.android.JsonScope.NONEMPTY_ARRAY; +import static com.bugsnag.android.JsonScope.NONEMPTY_DOCUMENT; +import static com.bugsnag.android.JsonScope.NONEMPTY_OBJECT; + +import java.io.Closeable; +import java.io.Flushable; +import java.io.IOException; +import java.io.Writer; + +/** + * Writes a JSON (RFC 7159) + * encoded value to a stream, one token at a time. The stream includes both + * literal values (strings, numbers, booleans and nulls) as well as the begin + * and end delimiters of objects and arrays. + * + *

Encoding JSON

+ * To encode your data as JSON, create a new {@code JsonWriter}. Each JSON + * document must contain one top-level array or object. Call methods on the + * writer as you walk the structure's contents, nesting arrays and objects as + * necessary: + *
    + *
  • To write arrays, first call {@link #beginArray()}. + * Write each of the array's elements with the appropriate {@link #value} + * methods or by nesting other arrays and objects. Finally close the array + * using {@link #endArray()}. + *
  • To write objects, first call {@link #beginObject()}. + * Write each of the object's properties by alternating calls to + * {@link #name} with the property's value. Write property values with the + * appropriate {@link #value} method or by nesting other objects or arrays. + * Finally close the object using {@link #endObject()}. + *
+ * + *

Example

+ * Suppose we'd like to encode a stream of messages such as the following:
 {@code
+ * [
+ *   {
+ *     "id": 912345678901,
+ *     "text": "How do I stream JSON in Java?",
+ *     "geo": null,
+ *     "user": {
+ *       "name": "json_newb",
+ *       "followers_count": 41
+ *      }
+ *   },
+ *   {
+ *     "id": 912345678902,
+ *     "text": "@json_newb just use JsonWriter!",
+ *     "geo": [50.454722, -104.606667],
+ *     "user": {
+ *       "name": "jesse",
+ *       "followers_count": 2
+ *     }
+ *   }
+ * ]}
+ * This code encodes the above structure:
   {@code
+ *   public void writeJsonStream(OutputStream out, List messages) throws IOException {
+ *     JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, "UTF-8"));
+ *     writer.setIndent("    ");
+ *     writeMessagesArray(writer, messages);
+ *     writer.close();
+ *   }
+ *
+ *   public void writeMessagesArray(JsonWriter writer, List messages) throws IOException {
+ *     writer.beginArray();
+ *     for (Message message : messages) {
+ *       writeMessage(writer, message);
+ *     }
+ *     writer.endArray();
+ *   }
+ *
+ *   public void writeMessage(JsonWriter writer, Message message) throws IOException {
+ *     writer.beginObject();
+ *     writer.name("id").value(message.getId());
+ *     writer.name("text").value(message.getText());
+ *     if (message.getGeo() != null) {
+ *       writer.name("geo");
+ *       writeDoublesArray(writer, message.getGeo());
+ *     } else {
+ *       writer.name("geo").nullValue();
+ *     }
+ *     writer.name("user");
+ *     writeUser(writer, message.getUser());
+ *     writer.endObject();
+ *   }
+ *
+ *   public void writeUser(JsonWriter writer, User user) throws IOException {
+ *     writer.beginObject();
+ *     writer.name("name").value(user.getMessage());
+ *     writer.name("followers_count").value(user.getFollowersCount());
+ *     writer.endObject();
+ *   }
+ *
+ *   public void writeDoublesArray(JsonWriter writer, List doubles) throws IOException {
+ *     writer.beginArray();
+ *     for (Double value : doubles) {
+ *       writer.value(value);
+ *     }
+ *     writer.endArray();
+ *   }}
+ * + *

Each {@code JsonWriter} may be used to write a single JSON stream. + * Instances of this class are not thread safe. Calls that would result in a + * malformed JSON string will fail with an {@link IllegalStateException}. + * + * @author Jesse Wilson + * @since 1.6 + */ +@SuppressWarnings("all") +class JsonWriter implements Closeable, Flushable { + + /* + * From RFC 7159, "All Unicode characters may be placed within the + * quotation marks except for the characters that must be escaped: + * quotation mark, reverse solidus, and the control characters + * (U+0000 through U+001F)." + * + * We also escape '\u2028' and '\u2029', which JavaScript interprets as + * newline characters. This prevents eval() from failing with a syntax + * error. http://code.google.com/p/google-gson/issues/detail?id=341 + */ + private static final String[] REPLACEMENT_CHARS; + private static final String[] HTML_SAFE_REPLACEMENT_CHARS; + static { + REPLACEMENT_CHARS = new String[128]; + for (int i = 0; i <= 0x1f; i++) { + REPLACEMENT_CHARS[i] = String.format("\\u%04x", i); + } + REPLACEMENT_CHARS['"'] = "\\\""; + REPLACEMENT_CHARS['\\'] = "\\\\"; + REPLACEMENT_CHARS['\t'] = "\\t"; + REPLACEMENT_CHARS['\b'] = "\\b"; + REPLACEMENT_CHARS['\n'] = "\\n"; + REPLACEMENT_CHARS['\r'] = "\\r"; + REPLACEMENT_CHARS['\f'] = "\\f"; + HTML_SAFE_REPLACEMENT_CHARS = REPLACEMENT_CHARS.clone(); + HTML_SAFE_REPLACEMENT_CHARS['<'] = "\\u003c"; + HTML_SAFE_REPLACEMENT_CHARS['>'] = "\\u003e"; + HTML_SAFE_REPLACEMENT_CHARS['&'] = "\\u0026"; + HTML_SAFE_REPLACEMENT_CHARS['='] = "\\u003d"; + HTML_SAFE_REPLACEMENT_CHARS['\''] = "\\u0027"; + } + + /** The output data, containing at most one top-level array or object. */ + private final Writer out; + + private int[] stack = new int[32]; + private int stackSize = 0; + { + push(EMPTY_DOCUMENT); + } + + /** + * A string containing a full set of spaces for a single level of + * indentation, or null for no pretty printing. + */ + private String indent; + + /** + * The name/value separator; either ":" or ": ". + */ + private String separator = ":"; + + private boolean lenient; + + private boolean htmlSafe; + + private String deferredName; + + private boolean serializeNulls = true; + + /** + * Creates a new instance that writes a JSON-encoded stream to {@code out}. + * For best performance, ensure {@link Writer} is buffered; wrapping in + * {@link java.io.BufferedWriter BufferedWriter} if necessary. + */ + public JsonWriter(Writer out) { + if (out == null) { + throw new NullPointerException("out == null"); + } + this.out = out; + } + + /** + * Sets the indentation string to be repeated for each level of indentation + * in the encoded document. If {@code indent.isEmpty()} the encoded document + * will be compact. Otherwise the encoded document will be more + * human-readable. + * + * @param indent a string containing only whitespace. + */ + public final void setIndent(String indent) { + if (indent.length() == 0) { + this.indent = null; + this.separator = ":"; + } else { + this.indent = indent; + this.separator = ": "; + } + } + + /** + * Configure this writer to relax its syntax rules. By default, this writer + * only emits well-formed JSON as specified by RFC 7159. Setting the writer + * to lenient permits the following: + *

    + *
  • Top-level values of any type. With strict writing, the top-level + * value must be an object or an array. + *
  • Numbers may be {@link Double#isNaN() NaNs} or {@link + * Double#isInfinite() infinities}. + *
+ */ + public final void setLenient(boolean lenient) { + this.lenient = lenient; + } + + /** + * Returns true if this writer has relaxed syntax rules. + */ + public boolean isLenient() { + return lenient; + } + + /** + * Configure this writer to emit JSON that's safe for direct inclusion in HTML + * and XML documents. This escapes the HTML characters {@code <}, {@code >}, + * {@code &} and {@code =} before writing them to the stream. Without this + * setting, your XML/HTML encoder should replace these characters with the + * corresponding escape sequences. + */ + public final void setHtmlSafe(boolean htmlSafe) { + this.htmlSafe = htmlSafe; + } + + /** + * Returns true if this writer writes JSON that's safe for inclusion in HTML + * and XML documents. + */ + public final boolean isHtmlSafe() { + return htmlSafe; + } + + /** + * Sets whether object members are serialized when their value is null. + * This has no impact on array elements. The default is true. + */ + public final void setSerializeNulls(boolean serializeNulls) { + this.serializeNulls = serializeNulls; + } + + /** + * Returns true if object members are serialized when their value is null. + * This has no impact on array elements. The default is true. + */ + public final boolean getSerializeNulls() { + return serializeNulls; + } + + /** + * Begins encoding a new array. Each call to this method must be paired with + * a call to {@link #endArray}. + * + * @return this writer. + */ + public JsonWriter beginArray() throws IOException { + writeDeferredName(); + return open(EMPTY_ARRAY, "["); + } + + /** + * Ends encoding the current array. + * + * @return this writer. + */ + public JsonWriter endArray() throws IOException { + return close(EMPTY_ARRAY, NONEMPTY_ARRAY, "]"); + } + + /** + * Begins encoding a new object. Each call to this method must be paired + * with a call to {@link #endObject}. + * + * @return this writer. + */ + public JsonWriter beginObject() throws IOException { + writeDeferredName(); + return open(EMPTY_OBJECT, "{"); + } + + /** + * Ends encoding the current object. + * + * @return this writer. + */ + public JsonWriter endObject() throws IOException { + return close(EMPTY_OBJECT, NONEMPTY_OBJECT, "}"); + } + + /** + * Enters a new scope by appending any necessary whitespace and the given + * bracket. + */ + private JsonWriter open(int empty, String openBracket) throws IOException { + beforeValue(); + push(empty); + out.write(openBracket); + return this; + } + + /** + * Closes the current scope by appending any necessary whitespace and the + * given bracket. + */ + private JsonWriter close(int empty, int nonempty, String closeBracket) + throws IOException { + int context = peek(); + if (context != nonempty && context != empty) { + throw new IllegalStateException("Nesting problem."); + } + if (deferredName != null) { + throw new IllegalStateException("Dangling name: " + deferredName); + } + + stackSize--; + if (context == nonempty) { + newline(); + } + out.write(closeBracket); + return this; + } + + private void push(int newTop) { + if (stackSize == stack.length) { + int[] newStack = new int[stackSize * 2]; + System.arraycopy(stack, 0, newStack, 0, stackSize); + stack = newStack; + } + stack[stackSize++] = newTop; + } + + /** + * Returns the value on the top of the stack. + */ + private int peek() { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + return stack[stackSize - 1]; + } + + /** + * Replace the value on the top of the stack with the given value. + */ + private void replaceTop(int topOfStack) { + stack[stackSize - 1] = topOfStack; + } + + /** + * Encodes the property name. + * + * @param name the name of the forthcoming value. May not be null. + * @return this writer. + */ + public JsonWriter name(String name) throws IOException { + if (name == null) { + throw new NullPointerException("name == null"); + } + if (deferredName != null) { + throw new IllegalStateException(); + } + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + deferredName = name; + return this; + } + + private void writeDeferredName() throws IOException { + if (deferredName != null) { + beforeName(); + string(deferredName); + deferredName = null; + } + } + + /** + * Encodes {@code value}. + * + * @param value the literal string value, or null to encode a null literal. + * @return this writer. + */ + public JsonWriter value(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + string(value); + return this; + } + + /** + * Writes {@code value} directly to the writer without quoting or + * escaping. + * + * @param value the literal string value, or null to encode a null literal. + * @return this writer. + */ + public JsonWriter jsonValue(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + out.append(value); + return this; + } + + /** + * Encodes {@code null}. + * + * @return this writer. + */ + public JsonWriter nullValue() throws IOException { + if (deferredName != null) { + if (serializeNulls) { + writeDeferredName(); + } else { + deferredName = null; + return this; // skip the name and the value + } + } + beforeValue(); + out.write("null"); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public JsonWriter value(boolean value) throws IOException { + writeDeferredName(); + beforeValue(); + out.write(value ? "true" : "false"); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public JsonWriter value(Boolean value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + out.write(value ? "true" : "false"); + return this; + } + + /** + * Encodes {@code value}. + * + * @param value a finite value. May not be {@link Double#isNaN() NaNs} or + * {@link Double#isInfinite() infinities}. + * @return this writer. + */ + public JsonWriter value(double value) throws IOException { + writeDeferredName(); + if (!lenient && (Double.isNaN(value) || Double.isInfinite(value))) { + throw new IllegalArgumentException("Numeric values must be finite, but was " + value); + } + beforeValue(); + out.append(Double.toString(value)); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public JsonWriter value(long value) throws IOException { + writeDeferredName(); + beforeValue(); + out.write(Long.toString(value)); + return this; + } + + /** + * Encodes {@code value}. + * + * @param value a finite value. May not be {@link Double#isNaN() NaNs} or + * {@link Double#isInfinite() infinities}. + * @return this writer. + */ + public JsonWriter value(Number value) throws IOException { + if (value == null) { + return nullValue(); + } + + writeDeferredName(); + String string = value.toString(); + if (!lenient + && (string.equals("-Infinity") || string.equals("Infinity") || string.equals("NaN"))) { + throw new IllegalArgumentException("Numeric values must be finite, but was " + value); + } + beforeValue(); + out.append(string); + return this; + } + + /** + * Ensures all buffered data is written to the underlying {@link Writer} + * and flushes that writer. + */ + public void flush() throws IOException { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + out.flush(); + } + + /** + * Flushes and closes this writer and the underlying {@link Writer}. + * + * @throws IOException if the JSON document is incomplete. + */ + public void close() throws IOException { + out.close(); + + int size = stackSize; + if (size > 1 || size == 1 && stack[size - 1] != NONEMPTY_DOCUMENT) { + throw new IOException("Incomplete document"); + } + stackSize = 0; + } + + private void string(String value) throws IOException { + String[] replacements = htmlSafe ? HTML_SAFE_REPLACEMENT_CHARS : REPLACEMENT_CHARS; + out.write("\""); + int last = 0; + int length = value.length(); + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + String replacement; + if (c < 128) { + replacement = replacements[c]; + if (replacement == null) { + continue; + } + } else if (c == '\u2028') { + replacement = "\\u2028"; + } else if (c == '\u2029') { + replacement = "\\u2029"; + } else { + continue; + } + if (last < i) { + out.write(value, last, i - last); + } + out.write(replacement); + last = i + 1; + } + if (last < length) { + out.write(value, last, length - last); + } + out.write("\""); + } + + private void newline() throws IOException { + if (indent == null) { + return; + } + + out.write("\n"); + for (int i = 1, size = stackSize; i < size; i++) { + out.write(indent); + } + } + + /** + * Inserts any necessary separators and whitespace before a name. Also + * adjusts the stack to expect the name's value. + */ + private void beforeName() throws IOException { + int context = peek(); + if (context == NONEMPTY_OBJECT) { // first in object + out.write(','); + } else if (context != EMPTY_OBJECT) { // not in an object! + throw new IllegalStateException("Nesting problem."); + } + newline(); + replaceTop(DANGLING_NAME); + } + + /** + * Inserts any necessary separators and whitespace before a literal value, + * inline array, or inline object. Also adjusts the stack to expect either a + * closing bracket or another element. + */ + @SuppressWarnings("fallthrough") + void beforeValue() throws IOException { + switch (peek()) { + case NONEMPTY_DOCUMENT: + if (!lenient) { + throw new IllegalStateException( + "JSON must have only one top-level value."); + } + // fall-through + case EMPTY_DOCUMENT: // first in document + replaceTop(NONEMPTY_DOCUMENT); + break; + + case EMPTY_ARRAY: // first in array + replaceTop(NONEMPTY_ARRAY); + newline(); + break; + + case NONEMPTY_ARRAY: // another in array + out.append(','); + newline(); + break; + + case DANGLING_NAME: // value for name + out.append(separator); + replaceTop(NONEMPTY_OBJECT); + break; + + default: + throw new IllegalStateException("Nesting problem."); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/LastRunInfo.kt b/app/src/main/java/com/bugsnag/android/LastRunInfo.kt new file mode 100644 index 0000000000..9797076660 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/LastRunInfo.kt @@ -0,0 +1,26 @@ +package com.bugsnag.android + +/** + * Provides information about the last launch of the application, if there was one. + */ +class LastRunInfo( + + /** + * The number times the app has consecutively crashed during its launch period. + */ + val consecutiveLaunchCrashes: Int, + + /** + * Whether the last app run ended with a crash, or was abnormally terminated by the system. + */ + val crashed: Boolean, + + /** + * True if the previous app run ended with a crash during its launch period. + */ + val crashedDuringLaunch: Boolean +) { + override fun toString(): String { + return "LastRunInfo(consecutiveLaunchCrashes=$consecutiveLaunchCrashes, crashed=$crashed, crashedDuringLaunch=$crashedDuringLaunch)" + } +} diff --git a/app/src/main/java/com/bugsnag/android/LastRunInfoStore.kt b/app/src/main/java/com/bugsnag/android/LastRunInfoStore.kt new file mode 100644 index 0000000000..d79fb69a47 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/LastRunInfoStore.kt @@ -0,0 +1,95 @@ +package com.bugsnag.android + +import java.io.File +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.withLock + +private const val KEY_VALUE_DELIMITER = "=" +private const val KEY_CONSECUTIVE_LAUNCH_CRASHES = "consecutiveLaunchCrashes" +private const val KEY_CRASHED = "crashed" +private const val KEY_CRASHED_DURING_LAUNCH = "crashedDuringLaunch" + +/** + * Persists/loads [LastRunInfo] on disk, which allows Bugsnag to determine + * whether the previous application launch crashed or not. This class is thread-safe. + */ +internal class LastRunInfoStore(config: ImmutableConfig) { + + val file: File = File(config.persistenceDirectory, "last-run-info") + private val logger: Logger = config.logger + private val lock = ReentrantReadWriteLock() + + fun persist(lastRunInfo: LastRunInfo) { + lock.writeLock().withLock { + try { + persistImpl(lastRunInfo) + } catch (exc: Throwable) { + logger.w("Unexpectedly failed to persist LastRunInfo.", exc) + } + } + } + + private fun persistImpl(lastRunInfo: LastRunInfo) { + val text = KeyValueWriter().apply { + add(KEY_CONSECUTIVE_LAUNCH_CRASHES, lastRunInfo.consecutiveLaunchCrashes) + add(KEY_CRASHED, lastRunInfo.crashed) + add(KEY_CRASHED_DURING_LAUNCH, lastRunInfo.crashedDuringLaunch) + }.toString() + file.writeText(text) + logger.d("Persisted: $text") + } + + fun load(): LastRunInfo? { + return lock.readLock().withLock { + try { + loadImpl() + } catch (exc: Throwable) { + logger.w("Unexpectedly failed to load LastRunInfo.", exc) + null + } + } + } + + private fun loadImpl(): LastRunInfo? { + if (!file.exists()) { + return null + } + + val lines = file.readText().split("\n").filter { it.isNotBlank() } + + if (lines.size != 3) { + logger.w("Unexpected number of lines when loading LastRunInfo. Skipping load. $lines") + return null + } + + return try { + val consecutiveLaunchCrashes = lines[0].asIntValue(KEY_CONSECUTIVE_LAUNCH_CRASHES) + val crashed = lines[1].asBooleanValue(KEY_CRASHED) + val crashedDuringLaunch = lines[2].asBooleanValue(KEY_CRASHED_DURING_LAUNCH) + val runInfo = LastRunInfo(consecutiveLaunchCrashes, crashed, crashedDuringLaunch) + logger.d("Loaded: $runInfo") + runInfo + } catch (exc: NumberFormatException) { + // unlikely case where information was serialized incorrectly + logger.w("Failed to read consecutiveLaunchCrashes from saved lastRunInfo", exc) + null + } + } + + private fun String.asIntValue(key: String) = + substringAfter("$key$KEY_VALUE_DELIMITER").toInt() + + private fun String.asBooleanValue(key: String) = + substringAfter("$key$KEY_VALUE_DELIMITER").toBoolean() +} + +private class KeyValueWriter { + + private val sb = StringBuilder() + + fun add(key: String, value: Any) { + sb.appendln("$key$KEY_VALUE_DELIMITER$value") + } + + override fun toString() = sb.toString() +} diff --git a/app/src/main/java/com/bugsnag/android/LaunchCrashTracker.kt b/app/src/main/java/com/bugsnag/android/LaunchCrashTracker.kt new file mode 100644 index 0000000000..30774ac663 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/LaunchCrashTracker.kt @@ -0,0 +1,42 @@ +package com.bugsnag.android + +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Tracks whether the app is currently in its launch period. This creates a timer of + * configuration.launchDurationMillis, after which which the launch period is considered + * complete. If this value is zero, then the user must manually call markLaunchCompleted(). + */ +internal class LaunchCrashTracker @JvmOverloads constructor( + config: ImmutableConfig, + private val executor: ScheduledThreadPoolExecutor = ScheduledThreadPoolExecutor(1) +) : BaseObservable() { + + private val launching = AtomicBoolean(true) + private val logger = config.logger + + init { + val delay = config.launchDurationMillis + + if (delay > 0) { + executor.executeExistingDelayedTasksAfterShutdownPolicy = false + try { + executor.schedule({ markLaunchCompleted() }, delay, TimeUnit.MILLISECONDS) + } catch (exc: RejectedExecutionException) { + logger.w("Failed to schedule timer for LaunchCrashTracker", exc) + } + } + } + + fun markLaunchCompleted() { + executor.shutdown() + launching.set(false) + notifyObservers(StateEvent.UpdateIsLaunching(false)) + logger.d("App launch period marked as complete") + } + + fun isLaunching() = launching.get() +} diff --git a/app/src/main/java/com/bugsnag/android/LibraryLoader.java b/app/src/main/java/com/bugsnag/android/LibraryLoader.java new file mode 100644 index 0000000000..f8dcb51a71 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/LibraryLoader.java @@ -0,0 +1,31 @@ +package com.bugsnag.android; + +import java.util.concurrent.atomic.AtomicBoolean; + +class LibraryLoader { + + private AtomicBoolean attemptedLoad = new AtomicBoolean(); + + /** + * Attempts to load a native library, returning false if the load was unsuccessful. + *

+ * If a load was attempted and failed, an error report will be sent using the supplied client + * and OnErrorCallback. + * + * @param name the library name + * @param client the bugsnag client + * @param callback an OnErrorCallback + * @return true if the library was loaded, false if not + */ + boolean loadLibrary(String name, Client client, OnErrorCallback callback) { + if (!attemptedLoad.getAndSet(true)) { + try { + System.loadLibrary(name); + return true; + } catch (UnsatisfiedLinkError error) { + client.notify(error, callback); + } + } + return false; + } +} diff --git a/app/src/main/java/com/bugsnag/android/Logger.kt b/app/src/main/java/com/bugsnag/android/Logger.kt new file mode 100644 index 0000000000..300e769a92 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Logger.kt @@ -0,0 +1,47 @@ +package com.bugsnag.android + +/** + * Logs internal messages from within the bugsnag notifier. + */ +interface Logger { + + /** + * Logs a message at the error level. + */ + fun e(msg: String): Unit = Unit + + /** + * Logs a message at the error level. + */ + fun e(msg: String, throwable: Throwable): Unit = Unit + + /** + * Logs a message at the warning level. + */ + fun w(msg: String): Unit = Unit + + /** + * Logs a message at the warning level. + */ + fun w(msg: String, throwable: Throwable): Unit = Unit + + /** + * Logs a message at the info level. + */ + fun i(msg: String): Unit = Unit + + /** + * Logs a message at the info level. + */ + fun i(msg: String, throwable: Throwable): Unit = Unit + + /** + * Logs a message at the debug level. + */ + fun d(msg: String): Unit = Unit + + /** + * Logs a message at the debug level. + */ + fun d(msg: String, throwable: Throwable): Unit = Unit +} diff --git a/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt new file mode 100644 index 0000000000..7f76a24271 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt @@ -0,0 +1,149 @@ +package com.bugsnag.android + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.annotation.VisibleForTesting +import java.lang.IllegalArgumentException + +internal class ManifestConfigLoader { + + companion object { + // mandatory + private const val BUGSNAG_NS = "com.bugsnag.android" + private const val API_KEY = "$BUGSNAG_NS.API_KEY" + internal const val BUILD_UUID = "$BUGSNAG_NS.BUILD_UUID" + + // detection + private const val AUTO_TRACK_SESSIONS = "$BUGSNAG_NS.AUTO_TRACK_SESSIONS" + private const val AUTO_DETECT_ERRORS = "$BUGSNAG_NS.AUTO_DETECT_ERRORS" + private const val PERSIST_USER = "$BUGSNAG_NS.PERSIST_USER" + private const val SEND_THREADS = "$BUGSNAG_NS.SEND_THREADS" + + // endpoints + private const val ENDPOINT_NOTIFY = "$BUGSNAG_NS.ENDPOINT_NOTIFY" + private const val ENDPOINT_SESSIONS = "$BUGSNAG_NS.ENDPOINT_SESSIONS" + + // app/project packages + private const val APP_VERSION = "$BUGSNAG_NS.APP_VERSION" + private const val VERSION_CODE = "$BUGSNAG_NS.VERSION_CODE" + private const val RELEASE_STAGE = "$BUGSNAG_NS.RELEASE_STAGE" + private const val ENABLED_RELEASE_STAGES = "$BUGSNAG_NS.ENABLED_RELEASE_STAGES" + private const val DISCARD_CLASSES = "$BUGSNAG_NS.DISCARD_CLASSES" + private const val PROJECT_PACKAGES = "$BUGSNAG_NS.PROJECT_PACKAGES" + private const val REDACTED_KEYS = "$BUGSNAG_NS.REDACTED_KEYS" + + // misc + private const val MAX_BREADCRUMBS = "$BUGSNAG_NS.MAX_BREADCRUMBS" + private const val MAX_PERSISTED_EVENTS = "$BUGSNAG_NS.MAX_PERSISTED_EVENTS" + private const val MAX_PERSISTED_SESSIONS = "$BUGSNAG_NS.MAX_PERSISTED_SESSIONS" + private const val LAUNCH_CRASH_THRESHOLD_MS = "$BUGSNAG_NS.LAUNCH_CRASH_THRESHOLD_MS" + private const val LAUNCH_DURATION_MILLIS = "$BUGSNAG_NS.LAUNCH_DURATION_MILLIS" + private const val SEND_LAUNCH_CRASHES_SYNCHRONOUSLY = "$BUGSNAG_NS.SEND_LAUNCH_CRASHES_SYNCHRONOUSLY" + private const val APP_TYPE = "$BUGSNAG_NS.APP_TYPE" + } + + fun load(ctx: Context, userSuppliedApiKey: String?): Configuration { + try { + val packageManager = ctx.packageManager + val packageName = ctx.packageName + val ai = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + val data = ai.metaData + return load(data, userSuppliedApiKey) + } catch (exc: Exception) { + throw IllegalStateException("Bugsnag is unable to read config from manifest.", exc) + } + } + + /** + * Populates the config with meta-data values supplied from the manifest as a Bundle. + * + * @param data the manifest bundle + */ + @VisibleForTesting + internal fun load(data: Bundle?, userSuppliedApiKey: String?): Configuration { + // get the api key from the JVM call, or lookup in the manifest if null + val apiKey = (userSuppliedApiKey ?: data?.getString(API_KEY)) + ?: throw IllegalArgumentException("No Bugsnag API key set") + val config = Configuration(apiKey) + + if (data != null) { + loadDetectionConfig(config, data) + loadEndpointsConfig(config, data) + loadAppConfig(config, data) + + // misc config + with(config) { + maxBreadcrumbs = data.getInt(MAX_BREADCRUMBS, maxBreadcrumbs) + maxPersistedEvents = data.getInt(MAX_PERSISTED_EVENTS, maxPersistedEvents) + maxPersistedSessions = data.getInt(MAX_PERSISTED_SESSIONS, maxPersistedSessions) + launchDurationMillis = data.getInt( + LAUNCH_CRASH_THRESHOLD_MS, + launchDurationMillis.toInt() + ).toLong() + launchDurationMillis = data.getInt( + LAUNCH_DURATION_MILLIS, + launchDurationMillis.toInt() + ).toLong() + sendLaunchCrashesSynchronously = data.getBoolean( + SEND_LAUNCH_CRASHES_SYNCHRONOUSLY, + sendLaunchCrashesSynchronously + ) + } + } + return config + } + + private fun loadDetectionConfig(config: Configuration, data: Bundle) { + with(config) { + autoTrackSessions = data.getBoolean(AUTO_TRACK_SESSIONS, autoTrackSessions) + autoDetectErrors = data.getBoolean(AUTO_DETECT_ERRORS, autoDetectErrors) + persistUser = data.getBoolean(PERSIST_USER, persistUser) + + val str = data.getString(SEND_THREADS) + + if (str != null) { + sendThreads = ThreadSendPolicy.fromString(str) + } + } + } + + private fun loadEndpointsConfig(config: Configuration, data: Bundle) { + if (data.containsKey(ENDPOINT_NOTIFY)) { + val endpoint = data.getString(ENDPOINT_NOTIFY, config.endpoints.notify) + val sessionEndpoint = data.getString(ENDPOINT_SESSIONS, config.endpoints.sessions) + config.endpoints = EndpointConfiguration(endpoint, sessionEndpoint) + } + } + + private fun loadAppConfig(config: Configuration, data: Bundle) { + with(config) { + releaseStage = data.getString(RELEASE_STAGE, config.releaseStage) + appVersion = data.getString(APP_VERSION, config.appVersion) + appType = data.getString(APP_TYPE, config.appType) + + if (data.containsKey(VERSION_CODE)) { + versionCode = data.getInt(VERSION_CODE) + } + if (data.containsKey(ENABLED_RELEASE_STAGES)) { + enabledReleaseStages = getStrArray(data, ENABLED_RELEASE_STAGES, enabledReleaseStages) + } + discardClasses = getStrArray(data, DISCARD_CLASSES, discardClasses) ?: emptySet() + projectPackages = getStrArray(data, PROJECT_PACKAGES, emptySet()) ?: emptySet() + redactedKeys = getStrArray(data, REDACTED_KEYS, redactedKeys) ?: emptySet() + } + } + + private fun getStrArray( + data: Bundle, + key: String, + default: Set? + ): Set? { + val delimitedStr = data.getString(key) + + return when (val ary = delimitedStr?.split(",")) { + null -> default + else -> ary.toSet() + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Metadata.kt b/app/src/main/java/com/bugsnag/android/Metadata.kt new file mode 100644 index 0000000000..a2e07ae2d1 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Metadata.kt @@ -0,0 +1,151 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.bugsnag.android + +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap + +/** + * A container for additional diagnostic information you'd like to send with + * every error report. + * + * Diagnostic information is presented on your Bugsnag dashboard in tabs. + */ +internal data class Metadata @JvmOverloads constructor( + internal val store: ConcurrentHashMap = ConcurrentHashMap() +) : JsonStream.Streamable, MetadataAware { + + val jsonStreamer: ObjectJsonStreamer = ObjectJsonStreamer() + + var redactedKeys: Set + get() = jsonStreamer.redactedKeys + set(value) { + jsonStreamer.redactedKeys = value + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + jsonStreamer.objectToStream(store, writer, true) + } + + override fun addMetadata(section: String, value: Map) { + value.entries.forEach { + addMetadata(section, it.key, it.value) + } + } + + override fun addMetadata(section: String, key: String, value: Any?) { + if (value == null) { + clearMetadata(section, key) + } else { + var tab = store[section] + if (tab !is MutableMap<*, *>) { + tab = ConcurrentHashMap() + store[section] = tab + } + insertValue(tab as MutableMap, key, value) + } + } + + private fun insertValue(map: MutableMap, key: String, newValue: Any) { + var obj = newValue + + // only merge if both the existing and new value are maps + val existingValue = map[key] + if (obj is MutableMap<*, *> && existingValue is MutableMap<*, *>) { + val maps = listOf(existingValue as Map, newValue as Map) + obj = mergeMaps(maps) + } + map[key] = obj + } + + override fun clearMetadata(section: String) { + store.remove(section) + } + + override fun clearMetadata(section: String, key: String) { + val tab = store[section] + + if (tab is MutableMap<*, *>) { + tab.remove(key) + + if (tab.isEmpty()) { + store.remove(section) + } + } + } + + override fun getMetadata(section: String): Map? { + return store[section] as (Map?) + } + + override fun getMetadata(section: String, key: String): Any? { + return when (val tab = store[section]) { + is Map<*, *> -> (tab as Map?)!![key] + else -> tab + } + } + + fun toMap(): ConcurrentHashMap { + val hashMap = ConcurrentHashMap(store) + + // deep copy each section + store.entries.forEach { + if (it.value is ConcurrentHashMap<*, *>) { + hashMap[it.key] = ConcurrentHashMap(it.value as ConcurrentHashMap<*, *>) + } + } + return hashMap + } + + companion object { + fun merge(vararg data: Metadata): Metadata { + val stores = data.map { it.toMap() } + val redactKeys = data.flatMap { it.jsonStreamer.redactedKeys } + val newMeta = Metadata(mergeMaps(stores)) + newMeta.redactedKeys = redactKeys.toSet() + return newMeta + } + + internal fun mergeMaps(data: List>): ConcurrentHashMap { + val keys = data.flatMap { it.keys }.toSet() + val result = ConcurrentHashMap() + + for (map in data) { + for (key in keys) { + getMergeValue(result, key, map) + } + } + return result + } + + private fun getMergeValue( + result: ConcurrentHashMap, + key: String, + map: Map + ) { + val baseValue = result[key] + val overridesValue = map[key] + + if (overridesValue != null) { + if (baseValue is Map<*, *> && overridesValue is Map<*, *>) { + // Both original and overrides are Maps, go deeper + val first = baseValue as Map? + val second = overridesValue as Map? + result[key] = mergeMaps(listOf(first!!, second!!)) + } else { + result[key] = overridesValue + } + } else { + if (baseValue != null) { // No collision, just use base value + result[key] = baseValue + } + } + } + } + + fun copy(): Metadata { + return this.copy(store = toMap()) + .also { it.redactedKeys = redactedKeys.toSet() } + } +} diff --git a/app/src/main/java/com/bugsnag/android/MetadataAware.kt b/app/src/main/java/com/bugsnag/android/MetadataAware.kt new file mode 100644 index 0000000000..eb3c6f4c02 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/MetadataAware.kt @@ -0,0 +1,12 @@ +package com.bugsnag.android + +internal interface MetadataAware { + fun addMetadata(section: String, value: Map) + fun addMetadata(section: String, key: String, value: Any?) + + fun clearMetadata(section: String) + fun clearMetadata(section: String, key: String) + + fun getMetadata(section: String): Map? + fun getMetadata(section: String, key: String): Any? +} diff --git a/app/src/main/java/com/bugsnag/android/MetadataState.kt b/app/src/main/java/com/bugsnag/android/MetadataState.kt new file mode 100644 index 0000000000..d89035b635 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/MetadataState.kt @@ -0,0 +1,67 @@ +package com.bugsnag.android + +import com.bugsnag.android.StateEvent.AddMetadata + +internal data class MetadataState(val metadata: Metadata = Metadata()) : + BaseObservable(), + MetadataAware { + + override fun addMetadata(section: String, value: Map) { + metadata.addMetadata(section, value) + notifyMetadataAdded(section, value) + } + + override fun addMetadata(section: String, key: String, value: Any?) { + metadata.addMetadata(section, key, value) + notifyMetadataAdded(section, key, value) + } + + override fun clearMetadata(section: String) { + metadata.clearMetadata(section) + notifyClear(section, null) + } + + override fun clearMetadata(section: String, key: String) { + metadata.clearMetadata(section, key) + notifyClear(section, key) + } + + private fun notifyClear(section: String, key: String?) { + when (key) { + null -> notifyObservers(StateEvent.ClearMetadataSection(section)) + else -> notifyObservers(StateEvent.ClearMetadataValue(section, key)) + } + } + + override fun getMetadata(section: String) = metadata.getMetadata(section) + override fun getMetadata(section: String, key: String) = metadata.getMetadata(section, key) + + /** + * Fires the initial observable messages for all the metadata which has been added before an + * Observer was added. This is used initially to populate the NDK with data. + */ + fun emitObservableEvent() { + val sections = metadata.store.keys + + for (section in sections) { + val data = metadata.getMetadata(section) + + data?.entries?.forEach { + notifyMetadataAdded(section, it.key, it.value) + } + } + } + + private fun notifyMetadataAdded(section: String, key: String, value: Any?) { + when (value) { + null -> notifyClear(section, key) + else -> notifyObservers(AddMetadata(section, key, metadata.getMetadata(section, key))) + } + } + + private fun notifyMetadataAdded(section: String, value: Map) { + value.entries.forEach { + notifyObservers(AddMetadata(section, it.key, metadata.getMetadata(it.key))) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/NativeInterface.java b/app/src/main/java/com/bugsnag/android/NativeInterface.java new file mode 100644 index 0000000000..032f74bd3e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/NativeInterface.java @@ -0,0 +1,407 @@ +package com.bugsnag.android; + +import android.annotation.SuppressLint; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Used as the entry point for native code to allow proguard to obfuscate other areas if needed + */ +public class NativeInterface { + + // The default charset on Android is always UTF-8 + private static Charset UTF8Charset = Charset.defaultCharset(); + + /** + * Static reference used if not using Bugsnag.start() + */ + @SuppressLint("StaticFieldLeak") + private static Client client; + + @NonNull + private static Client getClient() { + if (client != null) { + return client; + } else { + return Bugsnag.getClient(); + } + } + + /** + * Caches a client instance for responding to future events + */ + public static void setClient(@NonNull Client client) { + NativeInterface.client = client; + } + + @Nullable + public static String getContext() { + return getClient().getContext(); + } + + /** + * Retrieves the directory used to store native crash reports + */ + @NonNull + public static String getNativeReportPath() { + ImmutableConfig config = getClient().getConfig(); + File persistenceDirectory = config.getPersistenceDirectory(); + return new File(persistenceDirectory, "bugsnag-native").getAbsolutePath(); + } + + /** + * Retrieve user data from the static Client instance as a Map + */ + @NonNull + @SuppressWarnings("unused") + public static Map getUser() { + HashMap userData = new HashMap<>(); + User user = getClient().getUser(); + userData.put("id", user.getId()); + userData.put("name", user.getName()); + userData.put("email", user.getEmail()); + return userData; + } + + /** + * Retrieve app data from the static Client instance as a Map + */ + @NonNull + @SuppressWarnings("unused") + public static Map getApp() { + HashMap data = new HashMap<>(); + AppDataCollector source = getClient().getAppDataCollector(); + AppWithState app = source.generateAppWithState(); + data.put("version", app.getVersion()); + data.put("releaseStage", app.getReleaseStage()); + data.put("id", app.getId()); + data.put("type", app.getType()); + data.put("buildUUID", app.getBuildUuid()); + data.put("duration", app.getDuration()); + data.put("durationInForeground", app.getDurationInForeground()); + data.put("versionCode", app.getVersionCode()); + data.put("inForeground", app.getInForeground()); + data.put("isLaunching", app.isLaunching()); + data.put("binaryArch", app.getBinaryArch()); + data.putAll(source.getAppDataMetadata()); + return data; + } + + /** + * Retrieve device data from the static Client instance as a Map + */ + @NonNull + @SuppressWarnings("unused") + public static Map getDevice() { + DeviceDataCollector source = getClient().getDeviceDataCollector(); + HashMap deviceData = new HashMap<>(source.getDeviceMetadata()); + + DeviceWithState src = source.generateDeviceWithState(new Date().getTime()); + deviceData.put("freeDisk", src.getFreeDisk()); + deviceData.put("freeMemory", src.getFreeMemory()); + deviceData.put("orientation", src.getOrientation()); + deviceData.put("time", src.getTime()); + deviceData.put("cpuAbi", src.getCpuAbi()); + deviceData.put("jailbroken", src.getJailbroken()); + deviceData.put("id", src.getId()); + deviceData.put("locale", src.getLocale()); + deviceData.put("manufacturer", src.getManufacturer()); + deviceData.put("model", src.getModel()); + deviceData.put("osName", src.getOsName()); + deviceData.put("osVersion", src.getOsVersion()); + deviceData.put("runtimeVersions", src.getRuntimeVersions()); + deviceData.put("totalMemory", src.getTotalMemory()); + return deviceData; + } + + /** + * Retrieve the CPU ABI(s) for the current device + */ + @NonNull + public static String[] getCpuAbi() { + return getClient().getDeviceDataCollector().getCpuAbi(); + } + + /** + * Retrieves global metadata from the static Client instance as a Map + */ + @NonNull + public static Map getMetadata() { + return getClient().getMetadata(); + } + + /** + * Retrieves a list of stored breadcrumbs from the static Client instance + */ + @NonNull + public static List getBreadcrumbs() { + return getClient().getBreadcrumbs(); + } + + /** + * Sets the user + * + * @param id id + * @param email email + * @param name name + */ + @SuppressWarnings("unused") + public static void setUser(@Nullable final String id, + @Nullable final String email, + @Nullable final String name) { + Client client = getClient(); + client.setUser(id, email, name); + } + + /** + * Sets the user + * + * @param idBytes id + * @param emailBytes email + * @param nameBytes name + */ + @SuppressWarnings("unused") + public static void setUser(@Nullable final byte[] idBytes, + @Nullable final byte[] emailBytes, + @Nullable final byte[] nameBytes) { + String id = idBytes == null ? null : new String(idBytes, UTF8Charset); + String email = emailBytes == null ? null : new String(emailBytes, UTF8Charset); + String name = nameBytes == null ? null : new String(nameBytes, UTF8Charset); + setUser(id, email, name); + } + + /** + * Leave a "breadcrumb" log message + */ + public static void leaveBreadcrumb(@NonNull final String name, + @NonNull final BreadcrumbType type) { + if (name == null) { + return; + } + getClient().leaveBreadcrumb(name, new HashMap(), type); + } + + /** + * Leave a "breadcrumb" log message + */ + public static void leaveBreadcrumb(@NonNull final byte[] nameBytes, + @NonNull final BreadcrumbType type) { + if (nameBytes == null) { + return; + } + String name = new String(nameBytes, UTF8Charset); + getClient().leaveBreadcrumb(name, new HashMap(), type); + } + + /** + * Leaves a breadcrumb on the static client instance + */ + public static void leaveBreadcrumb(@NonNull String message, + @NonNull String type, + @NonNull Map metadata) { + String typeName = type.toUpperCase(Locale.US); + getClient().leaveBreadcrumb(message, metadata, BreadcrumbType.valueOf(typeName)); + } + + /** + * Remove metadata from subsequent exception reports + */ + public static void clearMetadata(@NonNull String section, @Nullable String key) { + if (key == null) { + getClient().clearMetadata(section); + } else { + getClient().clearMetadata(section, key); + } + } + + /** + * Add metadata to subsequent exception reports + */ + public static void addMetadata(@NonNull final String tab, + @Nullable final String key, + @Nullable final Object value) { + getClient().addMetadata(tab, key, value); + } + + /** + * Return the client report release stage + */ + @Nullable + public static String getReleaseStage() { + return getClient().getConfig().getReleaseStage(); + } + + /** + * Return the client session endpoint + */ + @NonNull + public static String getSessionEndpoint() { + return getClient().getConfig().getEndpoints().getSessions(); + } + + /** + * Return the client report endpoint + */ + @NonNull + public static String getEndpoint() { + return getClient().getConfig().getEndpoints().getNotify(); + } + + /** + * Set the client report context + */ + public static void setContext(@Nullable final String context) { + getClient().setContext(context); + } + + /** + * Set the binary arch used in the application + */ + public static void setBinaryArch(@NonNull final String binaryArch) { + getClient().setBinaryArch(binaryArch); + } + + /** + * Return the client report app version + */ + @Nullable + public static String getAppVersion() { + return getClient().getConfig().getAppVersion(); + } + + /** + * Return which release stages notify + */ + @Nullable + public static Collection getEnabledReleaseStages() { + return getClient().getConfig().getEnabledReleaseStages(); + } + + /** + * Update the current session with a given start time, ID, and event counts + */ + public static void registerSession(long startedAt, @Nullable String sessionId, + int unhandledCount, int handledCount) { + Client client = getClient(); + User user = client.getUser(); + Date startDate = startedAt > 0 ? new Date(startedAt) : null; + client.getSessionTracker().registerExistingSession(startDate, sessionId, user, + unhandledCount, handledCount); + } + + /** + * Deliver a report, serialized as an event JSON payload. + * + * @param releaseStageBytes The release stage in which the event was + * captured. Used to determine whether the report + * should be discarded, based on configured release + * stages + * @param payloadBytes The raw JSON payload of the event + * @param apiKey The apiKey for the event + * @param isLaunching whether the crash occurred when the app was launching + */ + @SuppressWarnings("unused") + public static void deliverReport(@Nullable byte[] releaseStageBytes, + @NonNull byte[] payloadBytes, + @NonNull String apiKey, + boolean isLaunching) { + if (payloadBytes == null) { + return; + } + String payload = new String(payloadBytes, UTF8Charset); + String releaseStage = releaseStageBytes == null + ? null + : new String(releaseStageBytes, UTF8Charset); + Client client = getClient(); + ImmutableConfig config = client.getConfig(); + if (releaseStage == null + || releaseStage.length() == 0 + || config.shouldNotifyForReleaseStage()) { + EventStore eventStore = client.getEventStore(); + + String filename = eventStore.getNdkFilename(payload, apiKey); + if (isLaunching) { + filename = filename.replace(".json", "startupcrash.json"); + } + eventStore.enqueueContentForDelivery(payload, filename); + } + } + + /** + * Notifies using the Android SDK + * + * @param nameBytes the error name + * @param messageBytes the error message + * @param severity the error severity + * @param stacktrace a stacktrace + */ + public static void notify(@NonNull final byte[] nameBytes, + @NonNull final byte[] messageBytes, + @NonNull final Severity severity, + @NonNull final StackTraceElement[] stacktrace) { + if (nameBytes == null || messageBytes == null || stacktrace == null) { + return; + } + String name = new String(nameBytes, UTF8Charset); + String message = new String(messageBytes, UTF8Charset); + notify(name, message, severity, stacktrace); + } + + /** + * Notifies using the Android SDK + * + * @param name the error name + * @param message the error message + * @param severity the error severity + * @param stacktrace a stacktrace + */ + public static void notify(@NonNull final String name, + @NonNull final String message, + @NonNull final Severity severity, + @NonNull final StackTraceElement[] stacktrace) { + Throwable exc = new RuntimeException(); + exc.setStackTrace(stacktrace); + + getClient().notify(exc, new OnErrorCallback() { + @Override + public boolean onError(@NonNull Event event) { + event.updateSeverityInternal(severity); + List errors = event.getErrors(); + Error error = event.getErrors().get(0); + + // update the error's type to C + if (!errors.isEmpty()) { + error.setErrorClass(name); + error.setErrorMessage(message); + + for (Error err : errors) { + err.setType(ErrorType.C); + } + } + return true; + } + }); + } + + @NonNull + public static Event createEvent(@Nullable Throwable exc, + @NonNull Client client, + @NonNull SeverityReason severityReason) { + Metadata metadata = client.getMetadataState().getMetadata(); + return new Event(exc, client.getConfig(), severityReason, metadata, client.logger); + } + + @NonNull + public static Logger getLogger() { + return getClient().getConfig().getLogger(); + } +} diff --git a/app/src/main/java/com/bugsnag/android/NativeStackframe.kt b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt new file mode 100644 index 0000000000..d58650202d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt @@ -0,0 +1,61 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Represents a single native stackframe + */ +class NativeStackframe internal constructor( + + /** + * The name of the method that was being executed + */ + var method: String?, + + /** + * The location of the source file + */ + var file: String?, + + /** + * The line number within the source file this stackframe refers to + */ + var lineNumber: Number?, + + /** + * The address of the instruction where the event occurred. + */ + var frameAddress: Long?, + + /** + * The address of the function where the event occurred. + */ + var symbolAddress: Long?, + + /** + * The address of the library where the event occurred. + */ + var loadAddress: Long? +) : JsonStream.Streamable { + + /** + * The type of the error + */ + var type: ErrorType? = ErrorType.C + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("method").value(method) + writer.name("file").value(file) + writer.name("lineNumber").value(lineNumber) + writer.name("frameAddress").value(frameAddress) + writer.name("symbolAddress").value(symbolAddress) + writer.name("loadAddress").value(loadAddress) + + type?.let { + writer.name("type").value(it.desc) + } + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/NoopLogger.kt b/app/src/main/java/com/bugsnag/android/NoopLogger.kt new file mode 100644 index 0000000000..c5f5228ab0 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/NoopLogger.kt @@ -0,0 +1,3 @@ +package com.bugsnag.android + +internal object NoopLogger : Logger diff --git a/app/src/main/java/com/bugsnag/android/Notifier.kt b/app/src/main/java/com/bugsnag/android/Notifier.kt new file mode 100644 index 0000000000..b56bea150f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Notifier.kt @@ -0,0 +1,31 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Information about this library, including name and version. + */ +class Notifier @JvmOverloads constructor( + var name: String = "Android Bugsnag Notifier", + var version: String = "5.9.2", + var url: String = "https://bugsnag.com" +) : JsonStream.Streamable { + + var dependencies = listOf() + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("name").value(name) + writer.name("version").value(version) + writer.name("url").value(url) + + if (dependencies.isNotEmpty()) { + writer.name("dependencies") + writer.beginArray() + dependencies.forEach { writer.value(it) } + writer.endArray() + } + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/ObjectJsonStreamer.kt b/app/src/main/java/com/bugsnag/android/ObjectJsonStreamer.kt new file mode 100644 index 0000000000..36933818b8 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ObjectJsonStreamer.kt @@ -0,0 +1,67 @@ +package com.bugsnag.android + +import java.io.IOException +import java.lang.reflect.Array + +internal class ObjectJsonStreamer { + + companion object { + private const val REDACTED_PLACEHOLDER = "[REDACTED]" + private const val OBJECT_PLACEHOLDER = "[OBJECT]" + } + + var redactedKeys = setOf("password") + + // Write complex/nested values to a JsonStreamer + @Throws(IOException::class) + fun objectToStream(obj: Any?, writer: JsonStream, shouldRedactKeys: Boolean = false) { + when { + obj == null -> writer.nullValue() + obj is String -> writer.value(obj) + obj is Number -> writer.value(obj) + obj is Boolean -> writer.value(obj) + obj is JsonStream.Streamable -> obj.toStream(writer) + obj is Map<*, *> -> mapToStream(writer, obj, shouldRedactKeys) + obj is Collection<*> -> collectionToStream(writer, obj) + obj.javaClass.isArray -> arrayToStream(writer, obj) + else -> writer.value(OBJECT_PLACEHOLDER) + } + } + + private fun mapToStream(writer: JsonStream, obj: Map<*, *>, shouldRedactKeys: Boolean) { + writer.beginObject() + obj.entries.forEach { + val keyObj = it.key + if (keyObj is String) { + writer.name(keyObj) + if (shouldRedactKeys && isRedactedKey(keyObj)) { + writer.value(REDACTED_PLACEHOLDER) + } else { + objectToStream(it.value, writer, shouldRedactKeys) + } + } + } + writer.endObject() + } + + private fun collectionToStream(writer: JsonStream, obj: Collection<*>) { + writer.beginArray() + obj.forEach { objectToStream(it, writer) } + writer.endArray() + } + + private fun arrayToStream(writer: JsonStream, obj: Any) { + // Primitive array objects + writer.beginArray() + val length = Array.getLength(obj) + var i = 0 + while (i < length) { + objectToStream(Array.get(obj, i), writer) + i += 1 + } + writer.endArray() + } + + // Should this key be redacted + private fun isRedactedKey(key: String) = redactedKeys.any { key.contains(it) } +} diff --git a/app/src/main/java/com/bugsnag/android/OnBreadcrumbCallback.java b/app/src/main/java/com/bugsnag/android/OnBreadcrumbCallback.java new file mode 100644 index 0000000000..dc4fb72d2d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/OnBreadcrumbCallback.java @@ -0,0 +1,32 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; + +/** + * Add a "on breadcrumb" callback, to execute code before every + * breadcrumb captured by Bugsnag. + *

+ * You can use this to modify breadcrumbs before they are stored by Bugsnag. + * You can also return false from any callback to ignore a breadcrumb. + *

+ * For example: + *

+ * Bugsnag.onBreadcrumb(new OnBreadcrumbCallback() { + * public boolean onBreadcrumb(Breadcrumb breadcrumb) { + * return false; // ignore the breadcrumb + * } + * }) + */ +public interface OnBreadcrumbCallback { + + /** + * Runs the "on breadcrumb" callback. If the callback returns + * false any further OnBreadcrumbCallback callbacks will not be called + * and the breadcrumb will not be captured by Bugsnag. + * + * @param breadcrumb the breadcrumb to be captured by Bugsnag + * @see Breadcrumb + */ + boolean onBreadcrumb(@NonNull Breadcrumb breadcrumb); + +} diff --git a/app/src/main/java/com/bugsnag/android/OnErrorCallback.java b/app/src/main/java/com/bugsnag/android/OnErrorCallback.java new file mode 100644 index 0000000000..4f20584796 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/OnErrorCallback.java @@ -0,0 +1,24 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; + +/** + * A callback to be run before error reports are sent to Bugsnag. + *

+ *

You can use this to add or modify information attached to an error + * before it is sent to your dashboard. You can also return + * false from any callback to halt execution. + *

"on error" callbacks added via the JVM API do not run when a fatal C/C++ crash occurs. + */ +public interface OnErrorCallback { + + /** + * Runs the "on error" callback. If the callback returns + * false any further OnErrorCallback callbacks will not be called + * and the event will not be sent to Bugsnag. + * + * @param event the event to be sent to Bugsnag + * @see Event + */ + boolean onError(@NonNull Event event); +} diff --git a/app/src/main/java/com/bugsnag/android/OnSessionCallback.java b/app/src/main/java/com/bugsnag/android/OnSessionCallback.java new file mode 100644 index 0000000000..d54ed8fc90 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/OnSessionCallback.java @@ -0,0 +1,23 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; + +/** + * A callback to be run before sessions are sent to Bugsnag. + *

+ *

You can use this to add or modify information attached to a session + * before it is sent to your dashboard. You can also return + * false from any callback to halt execution. + */ +public interface OnSessionCallback { + + /** + * Runs the "on session" callback. If the callback returns + * false any further OnSessionCallback callbacks will not be called + * and the session will not be sent to Bugsnag. + * + * @param session the session to be sent to Bugsnag + * @see Session + */ + boolean onSession(@NonNull Session session); +} diff --git a/app/src/main/java/com/bugsnag/android/Plugin.kt b/app/src/main/java/com/bugsnag/android/Plugin.kt new file mode 100644 index 0000000000..f477beba9a --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Plugin.kt @@ -0,0 +1,19 @@ +package com.bugsnag.android + +/** + * A plugin allows for additional functionality to be added to the Bugsnag SDK. + */ +interface Plugin { + + /** + * Loads a plugin with the given Client. When this method is invoked the plugin should + * activate its behaviour - for example, by capturing an additional source of errors. + */ + fun load(client: Client) + + /** + * Unloads a plugin. When this is invoked the plugin should cease all custom behaviour and + * restore the application to its unloaded state. + */ + fun unload() +} diff --git a/app/src/main/java/com/bugsnag/android/PluginClient.kt b/app/src/main/java/com/bugsnag/android/PluginClient.kt new file mode 100644 index 0000000000..8d3671ee42 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/PluginClient.kt @@ -0,0 +1,47 @@ +package com.bugsnag.android + +internal class PluginClient( + userPlugins: Set, + immutableConfig: ImmutableConfig, + private val logger: Logger +) { + + protected val plugins: Set + + init { + val set = mutableSetOf() + set.addAll(userPlugins) + + // instantiate ANR + NDK plugins by reflection as bugsnag-android-core has no + // direct dependency on the artefacts + if (immutableConfig.enabledErrorTypes.ndkCrashes) { + instantiatePlugin("com.bugsnag.android.NdkPlugin")?.let { set.add(it) } + } + if (immutableConfig.enabledErrorTypes.anrs) { + instantiatePlugin("com.bugsnag.android.AnrPlugin")?.let { set.add(it) } + } + instantiatePlugin("com.bugsnag.android.BugsnagReactNativePlugin")?.let { set.add(it) } + plugins = set.toSet() + } + + private fun instantiatePlugin(clz: String): Plugin? { + return try { + val pluginClz = Class.forName(clz) + pluginClz.newInstance() as Plugin + } catch (exc: ClassNotFoundException) { + logger.d("Plugin '$clz' is not on the classpath - functionality will not be enabled.") + null + } catch (exc: Throwable) { + logger.e("Failed to load plugin '$clz'", exc) + null + } + } + + fun loadPlugins(client: Client) = plugins.forEach { + try { + it.load(client) + } catch (exc: Throwable) { + logger.e("Failed to load plugin $it, continuing with initialisation.", exc) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/RootDetector.kt b/app/src/main/java/com/bugsnag/android/RootDetector.kt new file mode 100644 index 0000000000..5e9c7a7563 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/RootDetector.kt @@ -0,0 +1,141 @@ +package com.bugsnag.android + +import androidx.annotation.VisibleForTesting +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Attempts to detect whether the device is rooted. Root detection errs on the side of false + * negatives rather than false positives. + * + * This class will only give a reasonable indication that a device has been rooted - as it's + * possible to manipulate Java return values & native library loading, it will always be possible + * for a determined application to defeat these root checks. + */ +internal class RootDetector @JvmOverloads constructor( + private val deviceBuildInfo: DeviceBuildInfo = DeviceBuildInfo.defaultInfo(), + private val rootBinaryLocations: List = ROOT_INDICATORS, + private val buildProps: File = BUILD_PROP_FILE, + private val logger: Logger +) { + + companion object { + private val BUILD_PROP_FILE = File("/system/build.prop") + + private val ROOT_INDICATORS = listOf( + // Common binaries + "/system/xbin/su", + "/system/bin/su", + // < Android 5.0 + "/system/app/Superuser.apk", + "/system/app/SuperSU.apk", + // >= Android 5.0 + "/system/app/Superuser", + "/system/app/SuperSU", + // Fallback + "/system/xbin/daemonsu", + // Systemless root + "/su/bin" + ) + } + + private val libraryLoaded = AtomicBoolean(false) + + init { + try { + System.loadLibrary("bugsnag-root-detection") + libraryLoaded.set(true) + } catch (ignored: UnsatisfiedLinkError) { + // library couldn't load. This could be due to root detection countermeasures, + // or down to genuine OS level bugs with library loading - in either case + // Bugsnag will default to skipping the checks. + } + } + + /** + * Determines whether the device is rooted or not. + */ + fun isRooted(): Boolean { + return try { + checkBuildTags() || checkSuExists() || checkBuildProps() || checkRootBinaries() || nativeCheckRoot() + } catch (exc: Throwable) { + logger.w("Root detection failed", exc) + false + } + } + + /** + * Checks whether the su binary exists by running `which su`. A non-empty result + * indicates that the binary is present, which is a good indicator that the device + * may have been rooted. + */ + private fun checkSuExists(): Boolean = checkSuExists(ProcessBuilder()) + + /** + * Checks whether the build tags contain 'test-keys', which indicates that the OS was signed + * with non-standard keys. + */ + internal fun checkBuildTags(): Boolean = deviceBuildInfo.tags?.contains("test-keys") == true + + /** + * Checks whether common root binaries exist on disk, which are a good indicator of whether + * the device is rooted. + */ + internal fun checkRootBinaries(): Boolean { + runCatching { + for (candidate in rootBinaryLocations) { + if (File(candidate).exists()) { + return true + } + } + } + return false + } + + /** + * Checks the contents of /system/build.prop to see whether it contains dangerous properties. + * These properties give a good indication that a phone might be using a custom + * ROM and is therefore rooted. + */ + internal fun checkBuildProps(): Boolean { + runCatching { + return buildProps.bufferedReader().useLines { lines -> + lines + .map { line -> + line.replace("\\s".toRegex(), "") + }.filter { line -> + line.startsWith("ro.debuggable=[1]") || line.startsWith("ro.secure=[0]") + }.count() > 0 + } + } + return false + } + + @VisibleForTesting + internal fun checkSuExists(processBuilder: ProcessBuilder): Boolean { + processBuilder.command(listOf("which", "su")) + + var process: Process? = null + return try { + process = processBuilder.start() + val output = process.inputStream.bufferedReader().use(BufferedReader::readText) + output.isNotBlank() + } catch (ignored: IOException) { + false + } finally { + process?.destroy() + } + } + + private external fun performNativeRootChecks(): Boolean + + /** + * Performs root checks which require native code. + */ + private fun nativeCheckRoot(): Boolean = when { + libraryLoaded.get() -> performNativeRootChecks() + else -> false + } +} diff --git a/app/src/main/java/com/bugsnag/android/Session.java b/app/src/main/java/com/bugsnag/android/Session.java new file mode 100644 index 0000000000..2211b4636d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Session.java @@ -0,0 +1,239 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents a contiguous session in an application. + */ +@SuppressWarnings("ConstantConditions") +public final class Session implements JsonStream.Streamable, UserAware { + + private final File file; + private final Notifier notifier; + private String id; + private Date startedAt; + private User user; + private final Logger logger; + private App app; + private Device device; + + private final AtomicBoolean autoCaptured = new AtomicBoolean(false); + private final AtomicInteger unhandledCount = new AtomicInteger(); + private final AtomicInteger handledCount = new AtomicInteger(); + private final AtomicBoolean tracked = new AtomicBoolean(false); + final AtomicBoolean isPaused = new AtomicBoolean(false); + + static Session copySession(Session session) { + Session copy = new Session(session.id, session.startedAt, session.user, + session.unhandledCount.get(), session.handledCount.get(), session.notifier, + session.logger); + copy.tracked.set(session.tracked.get()); + copy.autoCaptured.set(session.isAutoCaptured()); + return copy; + } + + Session(String id, Date startedAt, User user, boolean autoCaptured, + Notifier notifier, Logger logger) { + this(null, notifier, logger); + this.id = id; + this.startedAt = new Date(startedAt.getTime()); + this.user = user; + this.autoCaptured.set(autoCaptured); + } + + Session(String id, Date startedAt, User user, int unhandledCount, int handledCount, + Notifier notifier, Logger logger) { + this(id, startedAt, user, false, notifier, logger); + this.unhandledCount.set(unhandledCount); + this.handledCount.set(handledCount); + this.tracked.set(true); + } + + Session(File file, Notifier notifier, Logger logger) { + this.file = file; + this.logger = logger; + Notifier copy = new Notifier(notifier.getName(), notifier.getVersion(), notifier.getUrl()); + copy.setDependencies(new ArrayList<>(notifier.getDependencies())); + this.notifier = copy; + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to session." + property + ", ignoring"); + } + + /** + * Retrieves the session ID. This must be a unique value across all of your sessions. + */ + @NonNull + public String getId() { + return id; + } + + /** + * Sets the session ID. This must be a unique value across all of your sessions. + */ + public void setId(@NonNull String id) { + if (id != null) { + this.id = id; + } else { + logNull("id"); + } + } + + /** + * Gets the session start time. + */ + @NonNull + public Date getStartedAt() { + return startedAt; + } + + /** + * Sets the session start time. + */ + public void setStartedAt(@NonNull Date startedAt) { + if (startedAt != null) { + this.startedAt = startedAt; + } else { + logNull("startedAt"); + } + } + + /** + * Returns the currently set User information. + */ + @NonNull + @Override + public User getUser() { + return user; + } + + /** + * Sets the user associated with the session. + */ + @Override + public void setUser(@Nullable String id, @Nullable String email, @Nullable String name) { + user = new User(id, email, name); + } + + /** + * Information set by the notifier about your app can be found in this field. These values + * can be accessed and amended if necessary. + */ + @NonNull + public App getApp() { + return app; + } + + /** + * Information set by the notifier about your device can be found in this field. These values + * can be accessed and amended if necessary. + */ + @NonNull + public Device getDevice() { + return device; + } + + void setApp(App app) { + this.app = app; + } + + void setDevice(Device device) { + this.device = device; + } + + int getUnhandledCount() { + return unhandledCount.intValue(); + } + + int getHandledCount() { + return handledCount.intValue(); + } + + Session incrementHandledAndCopy() { + handledCount.incrementAndGet(); + return copySession(this); + } + + Session incrementUnhandledAndCopy() { + unhandledCount.incrementAndGet(); + return copySession(this); + } + + AtomicBoolean isTracked() { + return tracked; + } + + boolean isAutoCaptured() { + return autoCaptured.get(); + } + + void setAutoCaptured(boolean autoCaptured) { + this.autoCaptured.set(autoCaptured); + } + + /** + * Determines whether a cached session payload is v1 (where only the session is stored) + * or v2 (where the whole payload including app/device is stored). + * + * @return whether the payload is v2 + */ + boolean isV2Payload() { + return file != null && file.getName().endsWith("_v2.json"); + } + + Notifier getNotifier() { + return notifier; + } + + @Override + public void toStream(@NonNull JsonStream writer) throws IOException { + if (file != null) { + if (isV2Payload()) { + serializeV2Payload(writer); + } else { + serializeV1Payload(writer); + } + } else { + writer.beginObject(); + writer.name("notifier").value(notifier); + writer.name("app").value(app); + writer.name("device").value(device); + writer.name("sessions").beginArray(); + serializeSessionInfo(writer); + writer.endArray(); + writer.endObject(); + } + } + + private void serializeV2Payload(@NonNull JsonStream writer) throws IOException { + writer.value(file); + } + + private void serializeV1Payload(@NonNull JsonStream writer) throws IOException { + writer.beginObject(); + writer.name("notifier").value(notifier); + writer.name("app").value(app); + writer.name("device").value(device); + writer.name("sessions").beginArray(); + writer.value(file); + writer.endArray(); + writer.endObject(); + } + + void serializeSessionInfo(@NonNull JsonStream writer) throws IOException { + writer.beginObject(); + writer.name("id").value(id); + writer.name("startedAt").value(DateUtils.toIso8601(startedAt)); + writer.name("user").value(user); + writer.endObject(); + } +} diff --git a/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt b/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt new file mode 100644 index 0000000000..b43f860813 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SessionLifecycleCallback.kt @@ -0,0 +1,22 @@ +package com.bugsnag.android + +import android.app.Activity +import android.app.Application +import android.os.Bundle + +internal class SessionLifecycleCallback( + private val sessionTracker: SessionTracker +) : Application.ActivityLifecycleCallbacks { + + override fun onActivityStarted(activity: Activity) = + sessionTracker.onActivityStarted(activity.javaClass.simpleName) + + override fun onActivityStopped(activity: Activity) = + sessionTracker.onActivityStopped(activity.javaClass.simpleName) + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/app/src/main/java/com/bugsnag/android/SessionStore.java b/app/src/main/java/com/bugsnag/android/SessionStore.java new file mode 100644 index 0000000000..c3e9f35bc9 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SessionStore.java @@ -0,0 +1,54 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.Comparator; +import java.util.Locale; +import java.util.UUID; + +/** + * Store and flush Sessions which couldn't be sent immediately due to + * lack of network connectivity. + */ +class SessionStore extends FileStore { + + static final Comparator SESSION_COMPARATOR = new Comparator() { + @Override + public int compare(File lhs, File rhs) { + if (lhs == null && rhs == null) { + return 0; + } + if (lhs == null) { + return 1; + } + if (rhs == null) { + return -1; + } + String lhsName = lhs.getName(); + String rhsName = rhs.getName(); + return lhsName.compareTo(rhsName); + } + }; + + SessionStore(@NonNull ImmutableConfig config, + @NonNull Logger logger, + @Nullable Delegate delegate) { + super(new File(config.getPersistenceDirectory(), "bugsnag-sessions"), + config.getMaxPersistedSessions(), + SESSION_COMPARATOR, + logger, + delegate); + } + + @NonNull + @Override + String getFilename(Object object) { + return String.format(Locale.US, + "%s%d_v2.json", + UUID.randomUUID().toString(), + System.currentTimeMillis()); + } + +} diff --git a/app/src/main/java/com/bugsnag/android/SessionTracker.java b/app/src/main/java/com/bugsnag/android/SessionTracker.java new file mode 100644 index 0000000000..b1ef742274 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SessionTracker.java @@ -0,0 +1,401 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import java.io.File; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +class SessionTracker extends BaseObservable { + + private static final int DEFAULT_TIMEOUT_MS = 30000; + + private final Collection + foregroundActivities = new ConcurrentLinkedQueue<>(); + private final long timeoutMs; + + private final ImmutableConfig configuration; + private final CallbackState callbackState; + private final Client client; + final SessionStore sessionStore; + + // This most recent time an Activity was stopped. + private final AtomicLong lastExitedForegroundMs = new AtomicLong(0); + + // The first Activity in this 'session' was started at this time. + private final AtomicLong lastEnteredForegroundMs = new AtomicLong(0); + private final AtomicReference currentSession = new AtomicReference<>(); + private final ForegroundDetector foregroundDetector; + final BackgroundTaskService backgroundTaskService; + final Logger logger; + + SessionTracker(ImmutableConfig configuration, + CallbackState callbackState, + Client client, + SessionStore sessionStore, + Logger logger, + BackgroundTaskService backgroundTaskService) { + this(configuration, callbackState, client, DEFAULT_TIMEOUT_MS, + sessionStore, logger, backgroundTaskService); + } + + SessionTracker(ImmutableConfig configuration, + CallbackState callbackState, + Client client, + long timeoutMs, + SessionStore sessionStore, + Logger logger, + BackgroundTaskService backgroundTaskService) { + this.configuration = configuration; + this.callbackState = callbackState; + this.client = client; + this.timeoutMs = timeoutMs; + this.sessionStore = sessionStore; + this.foregroundDetector = new ForegroundDetector(client.getAppContext()); + this.backgroundTaskService = backgroundTaskService; + this.logger = logger; + notifyNdkInForeground(); + } + + /** + * Starts a new session with the given date and user. + *

+ * A session will only be created if {@link Configuration#getAutoTrackSessions()} returns + * true. + * + * @param date the session start date + * @param user the session user (if any) + */ + @Nullable + @VisibleForTesting + Session startNewSession(@NonNull Date date, @Nullable User user, + boolean autoCaptured) { + String id = UUID.randomUUID().toString(); + Session session = new Session(id, date, user, autoCaptured, client.getNotifier(), logger); + currentSession.set(session); + trackSessionIfNeeded(session); + return session; + } + + Session startSession(boolean autoCaptured) { + return startNewSession(new Date(), client.getUser(), autoCaptured); + } + + void pauseSession() { + Session session = currentSession.get(); + + if (session != null) { + session.isPaused.set(true); + notifyObservers(StateEvent.PauseSession.INSTANCE); + } + } + + boolean resumeSession() { + Session session = currentSession.get(); + boolean resumed; + + if (session == null) { + session = startSession(false); + resumed = false; + } else { + resumed = session.isPaused.compareAndSet(true, false); + } + + if (session != null) { + notifySessionStartObserver(session); + } + return resumed; + } + + private void notifySessionStartObserver(Session session) { + String startedAt = DateUtils.toIso8601(session.getStartedAt()); + notifyObservers(new StateEvent.StartSession(session.getId(), startedAt, + session.getHandledCount(), session.getUnhandledCount())); + } + + /** + * Cache details of a previously captured session. + * Append session details to all subsequent reports. + * + * @param date the session start date + * @param sessionId the unique session identifier + * @param user the session user (if any) + * @param unhandledCount the number of unhandled events which have occurred during the session + * @param handledCount the number of handled events which have occurred during the session + * @return the session + */ + @Nullable + Session registerExistingSession(@Nullable Date date, @Nullable String sessionId, + @Nullable User user, int unhandledCount, + int handledCount) { + Session session = null; + if (date != null && sessionId != null) { + session = new Session(sessionId, date, user, unhandledCount, handledCount, + client.getNotifier(), logger); + notifySessionStartObserver(session); + } else { + notifyObservers(StateEvent.PauseSession.INSTANCE); + } + currentSession.set(session); + return session; + } + + /** + * Determines whether or not a session should be tracked. If this is true, the session will be + * stored and sent to the Bugsnag API, otherwise no action will occur in this method. + * + * @param session the session + */ + private void trackSessionIfNeeded(final Session session) { + logger.d("SessionTracker#trackSessionIfNeeded() - session captured by Client"); + + boolean notifyForRelease = configuration.shouldNotifyForReleaseStage(); + + session.setApp(client.getAppDataCollector().generateApp()); + session.setDevice(client.getDeviceDataCollector().generateDevice()); + boolean deliverSession = callbackState.runOnSessionTasks(session, logger); + + if (deliverSession && notifyForRelease + && (configuration.getAutoTrackSessions() || !session.isAutoCaptured()) + && session.isTracked().compareAndSet(false, true)) { + notifySessionStartObserver(session); + + flushAsync(); + flushInMemorySession(session); + } + } + + @Nullable + Session getCurrentSession() { + Session session = currentSession.get(); + + if (session != null && !session.isPaused.get()) { + return session; + } + return null; + } + + /** + * Increments the unhandled error count on the current session, then returns a deep-copy + * of the current session. + * + * @return a copy of the current session, or null if no session has been started. + */ + Session incrementUnhandledAndCopy() { + Session session = getCurrentSession(); + if (session != null) { + return session.incrementUnhandledAndCopy(); + } + return null; + } + + /** + * Increments the handled error count on the current session, then returns a deep-copy + * of the current session. + * + * @return a copy of the current session, or null if no session has been started. + */ + Session incrementHandledAndCopy() { + Session session = getCurrentSession(); + if (session != null) { + return session.incrementHandledAndCopy(); + } + return null; + } + + /** + * Asynchronously flushes any session payloads stored on disk + */ + void flushAsync() { + try { + backgroundTaskService.submitTask(TaskType.SESSION_REQUEST, new Runnable() { + @Override + public void run() { + flushStoredSessions(); + } + }); + } catch (RejectedExecutionException ex) { + logger.w("Failed to flush session reports", ex); + } + } + + /** + * Attempts to flush session payloads stored on disk + */ + void flushStoredSessions() { + List storedFiles = sessionStore.findStoredFiles(); + + for (File storedFile : storedFiles) { + flushStoredSession(storedFile); + } + } + + void flushStoredSession(File storedFile) { + logger.d("SessionTracker#flushStoredSession() - attempting delivery"); + Session payload = new Session(storedFile, client.getNotifier(), logger); + + if (!payload.isV2Payload()) { // collect data here + payload.setApp(client.getAppDataCollector().generateApp()); + payload.setDevice(client.getDeviceDataCollector().generateDevice()); + } + + DeliveryStatus deliveryStatus = deliverSessionPayload(payload); + + switch (deliveryStatus) { + case DELIVERED: + sessionStore.deleteStoredFiles(Collections.singletonList(storedFile)); + logger.d("Sent 1 new session to Bugsnag"); + break; + case UNDELIVERED: + sessionStore.cancelQueuedFiles(Collections.singletonList(storedFile)); + logger.w("Leaving session payload for future delivery"); + break; + case FAILURE: + // drop bad data + logger.w("Deleting invalid session tracking payload"); + sessionStore.deleteStoredFiles(Collections.singletonList(storedFile)); + break; + default: + break; + } + } + + private void flushInMemorySession(final Session session) { + try { + backgroundTaskService.submitTask(TaskType.SESSION_REQUEST, new Runnable() { + @Override + public void run() { + deliverInMemorySession(session); + } + }); + } catch (RejectedExecutionException exception) { + // This is on the current thread but there isn't much else we can do + sessionStore.write(session); + } + } + + void deliverInMemorySession(Session session) { + try { + logger.d("SessionTracker#trackSessionIfNeeded() - attempting initial delivery"); + DeliveryStatus deliveryStatus = deliverSessionPayload(session); + + switch (deliveryStatus) { + case UNDELIVERED: + logger.w("Storing session payload for future delivery"); + sessionStore.write(session); + break; + case FAILURE: + logger.w("Dropping invalid session tracking payload"); + break; + case DELIVERED: + logger.d("Sent 1 new session to Bugsnag"); + break; + default: + break; + } + } catch (Exception exception) { + logger.w("Session tracking payload failed", exception); + } + } + + DeliveryStatus deliverSessionPayload(Session payload) { + DeliveryParams params = configuration.getSessionApiDeliveryParams(); + Delivery delivery = configuration.getDelivery(); + return delivery.deliver(payload, params); + } + + void onActivityStarted(String activityName) { + updateForegroundTracker(activityName, true, System.currentTimeMillis()); + } + + void onActivityStopped(String activityName) { + updateForegroundTracker(activityName, false, System.currentTimeMillis()); + } + + /** + * Tracks whether an activity is in the foreground or not. + *

+ * If an activity leaves the foreground, a timeout should be recorded (e.g. 30s), during which + * no new sessions should be automatically started. + *

+ * If an activity comes to the foreground and is the only foreground activity, a new session + * should be started, unless the app is within a timeout period. + * + * @param activityName the activity name + * @param activityStarting whether the activity is being started or not + * @param nowMs The current time in ms + */ + void updateForegroundTracker(String activityName, boolean activityStarting, long nowMs) { + if (activityStarting) { + long noActivityRunningForMs = nowMs - lastExitedForegroundMs.get(); + + //FUTURE:SM Race condition between isEmpty and put + if (foregroundActivities.isEmpty()) { + lastEnteredForegroundMs.set(nowMs); + + if (noActivityRunningForMs >= timeoutMs + && configuration.getAutoTrackSessions()) { + startNewSession(new Date(nowMs), client.getUser(), true); + } + } + foregroundActivities.add(activityName); + } else { + foregroundActivities.remove(activityName); + + if (foregroundActivities.isEmpty()) { + lastExitedForegroundMs.set(nowMs); + } + } + notifyNdkInForeground(); + } + + private void notifyNdkInForeground() { + Boolean inForeground = isInForeground(); + boolean foreground = inForeground != null ? inForeground : false; + notifyObservers(new StateEvent.UpdateInForeground(foreground, getContextActivity())); + } + + @Nullable + Boolean isInForeground() { + return foregroundDetector.isInForeground(); + } + + //FUTURE:SM This shouldnt be here + @Nullable + Long getDurationInForegroundMs(long nowMs) { + long durationMs = 0; + long sessionStartTimeMs = lastEnteredForegroundMs.get(); + + Boolean inForeground = isInForeground(); + + if (inForeground == null) { + return null; + } + if (inForeground && sessionStartTimeMs != 0) { + durationMs = nowMs - sessionStartTimeMs; + } + return durationMs > 0 ? durationMs : 0; + } + + @Nullable + String getContextActivity() { + if (foregroundActivities.isEmpty()) { + return null; + } else { + // linked hash set retains order of added activity and ensures uniqueness + // therefore obtain the most recently added + int size = foregroundActivities.size(); + String[] activities = foregroundActivities.toArray(new String[size]); + return activities[size - 1]; + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Severity.kt b/app/src/main/java/com/bugsnag/android/Severity.kt new file mode 100644 index 0000000000..d32349867b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Severity.kt @@ -0,0 +1,20 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * The severity of an Event, one of "error", "warning" or "info". + * + * By default, unhandled exceptions will be Severity.ERROR and handled + * exceptions sent with bugsnag.notify will be Severity.WARNING. + */ +enum class Severity(private val str: String) : JsonStream.Streamable { + ERROR("error"), + WARNING("warning"), + INFO("info"); + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.value(str) + } +} diff --git a/app/src/main/java/com/bugsnag/android/SeverityReason.java b/app/src/main/java/com/bugsnag/android/SeverityReason.java new file mode 100644 index 0000000000..1a472d8224 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SeverityReason.java @@ -0,0 +1,157 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; + +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +final class SeverityReason implements JsonStream.Streamable { + + @StringDef({REASON_UNHANDLED_EXCEPTION, REASON_STRICT_MODE, REASON_HANDLED_EXCEPTION, + REASON_USER_SPECIFIED, REASON_CALLBACK_SPECIFIED, REASON_PROMISE_REJECTION, + REASON_LOG, REASON_SIGNAL, REASON_ANR}) + @Retention(RetentionPolicy.SOURCE) + @interface SeverityReasonType { + } + + static final String REASON_UNHANDLED_EXCEPTION = "unhandledException"; + static final String REASON_STRICT_MODE = "strictMode"; + static final String REASON_HANDLED_EXCEPTION = "handledException"; + static final String REASON_USER_SPECIFIED = "userSpecifiedSeverity"; + static final String REASON_CALLBACK_SPECIFIED = "userCallbackSetSeverity"; + static final String REASON_PROMISE_REJECTION = "unhandledPromiseRejection"; + static final String REASON_SIGNAL = "signal"; + static final String REASON_LOG = "log"; + static final String REASON_ANR = "anrError"; + + @SeverityReasonType + private final String severityReasonType; + + @Nullable + private final String attributeValue; + + private final Severity defaultSeverity; + private Severity currentSeverity; + private boolean unhandled; + final boolean originalUnhandled; + + static SeverityReason newInstance(@SeverityReasonType String severityReasonType) { + return newInstance(severityReasonType, null, null); + } + + static SeverityReason newInstance(@SeverityReasonType String severityReasonType, + @Nullable Severity severity, + @Nullable String attrVal) { + + if (severityReasonType.equals(REASON_STRICT_MODE) && Intrinsics.isEmpty(attrVal)) { + throw new IllegalArgumentException("No reason supplied for strictmode"); + } + if (!(severityReasonType.equals(REASON_STRICT_MODE) + || severityReasonType.equals(REASON_LOG)) && !Intrinsics.isEmpty(attrVal)) { + throw new IllegalArgumentException("attributeValue should not be supplied"); + } + + switch (severityReasonType) { + case REASON_UNHANDLED_EXCEPTION: + case REASON_PROMISE_REJECTION: + case REASON_ANR: + return new SeverityReason(severityReasonType, Severity.ERROR, true, null); + case REASON_STRICT_MODE: + return new SeverityReason(severityReasonType, Severity.WARNING, true, attrVal); + case REASON_HANDLED_EXCEPTION: + return new SeverityReason(severityReasonType, Severity.WARNING, false, null); + case REASON_USER_SPECIFIED: + case REASON_CALLBACK_SPECIFIED: + return new SeverityReason(severityReasonType, severity, false, null); + case REASON_LOG: + return new SeverityReason(severityReasonType, severity, false, attrVal); + default: + String msg = String.format("Invalid argument '%s' for severityReason", + severityReasonType); + throw new IllegalArgumentException(msg); + } + } + + SeverityReason(String severityReasonType, Severity currentSeverity, boolean unhandled, + @Nullable String attributeValue) { + this(severityReasonType, currentSeverity, unhandled, unhandled, attributeValue); + } + + SeverityReason(String severityReasonType, Severity currentSeverity, boolean unhandled, + boolean originalUnhandled, @Nullable String attributeValue) { + this.severityReasonType = severityReasonType; + this.unhandled = unhandled; + this.originalUnhandled = originalUnhandled; + this.defaultSeverity = currentSeverity; + this.currentSeverity = currentSeverity; + this.attributeValue = attributeValue; + } + + String calculateSeverityReasonType() { + return defaultSeverity == currentSeverity ? severityReasonType : REASON_CALLBACK_SPECIFIED; + } + + Severity getCurrentSeverity() { + return currentSeverity; + } + + boolean getUnhandled() { + return unhandled; + } + + void setUnhandled(boolean unhandled) { + this.unhandled = unhandled; + } + + boolean getUnhandledOverridden() { + return unhandled != originalUnhandled; + } + + boolean isOriginalUnhandled() { + return originalUnhandled; + } + + @Nullable + String getAttributeValue() { + return attributeValue; + } + + void setCurrentSeverity(Severity severity) { + this.currentSeverity = severity; + } + + String getSeverityReasonType() { + return severityReasonType; + } + + @Override + public void toStream(@NonNull JsonStream writer) throws IOException { + writer.beginObject() + .name("type").value(calculateSeverityReasonType()) + .name("unhandledOverridden").value(getUnhandledOverridden()); + + if (attributeValue != null) { + String attributeKey = null; + switch (severityReasonType) { + case REASON_LOG: + attributeKey = "level"; + break; + case REASON_STRICT_MODE: + attributeKey = "violationType"; + break; + default: + break; + } + if (attributeKey != null) { + writer.name("attributes").beginObject() + .name(attributeKey).value(attributeValue) + .endObject(); + } + } + writer.endObject(); + } + +} diff --git a/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt b/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt new file mode 100644 index 0000000000..e8af658a60 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt @@ -0,0 +1,37 @@ +package com.bugsnag.android + +import android.annotation.SuppressLint +import android.content.Context + +/** + * Reads legacy information left in SharedPreferences and migrates it to the new location. + */ +internal class SharedPrefMigrator(context: Context) { + + private val prefs = context + .getSharedPreferences("com.bugsnag.android", Context.MODE_PRIVATE) + + fun loadDeviceId() = prefs.getString(INSTALL_ID_KEY, null) + + fun loadUser(deviceId: String?) = User( + prefs.getString(USER_ID_KEY, deviceId), + prefs.getString(USER_EMAIL_KEY, null), + prefs.getString(USER_NAME_KEY, null) + ) + + fun hasPrefs() = prefs.contains(INSTALL_ID_KEY) + + @SuppressLint("ApplySharedPref") + fun deleteLegacyPrefs() { + if (hasPrefs()) { + prefs.edit().clear().commit() + } + } + + companion object { + private const val INSTALL_ID_KEY = "install.iud" + private const val USER_ID_KEY = "user.id" + private const val USER_NAME_KEY = "user.name" + private const val USER_EMAIL_KEY = "user.email" + } +} diff --git a/app/src/main/java/com/bugsnag/android/Stackframe.kt b/app/src/main/java/com/bugsnag/android/Stackframe.kt new file mode 100644 index 0000000000..8db78bf67b --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Stackframe.kt @@ -0,0 +1,123 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Represents a single stackframe from a [Throwable] + */ +class Stackframe : JsonStream.Streamable { + /** + * The name of the method that was being executed + */ + var method: String? + set(value) { + nativeFrame?.method = value + field = value + } + + /** + * The location of the source file + */ + var file: String? + set(value) { + nativeFrame?.file = value + field = value + } + + /** + * The line number within the source file this stackframe refers to + */ + var lineNumber: Number? + set(value) { + nativeFrame?.lineNumber = value + field = value + } + + /** + * Whether the package is considered to be in your project for the purposes of grouping and + * readability on the Bugsnag dashboard. Project package names can be set in + * [Configuration.projectPackages] + */ + var inProject: Boolean? + + /** + * Lines of the code surrounding the frame, where the lineNumber is the key (React Native only) + */ + var code: Map? + + /** + * The column number of the frame (React Native only) + */ + var columnNumber: Number? + + /** + * The type of the error + */ + var type: ErrorType? = null + set(value) { + nativeFrame?.type = value + field = value + } + + @JvmOverloads + internal constructor( + method: String?, + file: String?, + lineNumber: Number?, + inProject: Boolean?, + code: Map? = null, + columnNumber: Number? = null + ) { + this.method = method + this.file = file + this.lineNumber = lineNumber + this.inProject = inProject + this.code = code + this.columnNumber = columnNumber + } + + private var nativeFrame: NativeStackframe? = null + + constructor(nativeFrame: NativeStackframe) : this( + nativeFrame.method, + nativeFrame.file, + nativeFrame.lineNumber, + false, + null + ) { + this.nativeFrame = nativeFrame + this.type = nativeFrame.type + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + val ndkFrame = nativeFrame + if (ndkFrame != null) { + ndkFrame.toStream(writer) + return + } + + writer.beginObject() + writer.name("method").value(method) + writer.name("file").value(file) + writer.name("lineNumber").value(lineNumber) + writer.name("inProject").value(inProject) + writer.name("columnNumber").value(columnNumber) + + type?.let { + writer.name("type").value(it.desc) + } + + code?.let { map: Map -> + writer.name("code") + + map.forEach { + writer.beginObject() + writer.name(it.key) + writer.value(it.value) + writer.endObject() + } + } + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/Stacktrace.kt b/app/src/main/java/com/bugsnag/android/Stacktrace.kt new file mode 100644 index 0000000000..a3deebc005 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Stacktrace.kt @@ -0,0 +1,83 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Serialize an exception stacktrace and mark frames as "in-project" + * where appropriate. + */ +internal class Stacktrace : JsonStream.Streamable { + + companion object { + private const val STACKTRACE_TRIM_LENGTH = 200 + + /** + * Calculates whether a stackframe is 'in project' or not by checking its class against + * [Configuration.getProjectPackages]. + * + * For example if the projectPackages included 'com.example', then + * the `com.example.Foo` class would be considered in project, but `org.example.Bar` would + * not. + */ + fun inProject(className: String, projectPackages: Collection): Boolean? { + for (packageName in projectPackages) { + if (className.startsWith(packageName)) { + return true + } + } + return null + } + + fun stacktraceFromJavaTrace( + stacktrace: Array, + projectPackages: Collection, + logger: Logger + ): Stacktrace { + val frames = stacktrace.mapNotNull { serializeStackframe(it, projectPackages, logger) } + return Stacktrace(frames) + } + + private fun serializeStackframe( + el: StackTraceElement, + projectPackages: Collection, + logger: Logger + ): Stackframe? { + try { + val methodName = when { + el.className.isNotEmpty() -> el.className + "." + el.methodName + else -> el.methodName + } + + return Stackframe( + methodName, + if (el.fileName == null) "Unknown" else el.fileName, + el.lineNumber, + inProject(el.className, projectPackages) + ) + } catch (lineEx: Exception) { + logger.w("Failed to serialize stacktrace", lineEx) + return null + } + } + } + + val trace: List + + constructor(frames: List) { + trace = limitTraceLength(frames) + } + + private fun limitTraceLength(frames: List): List { + return when { + frames.size >= STACKTRACE_TRIM_LENGTH -> frames.subList(0, STACKTRACE_TRIM_LENGTH) + else -> frames + } + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginArray() + trace.forEach { writer.value(it) } + writer.endArray() + } +} diff --git a/app/src/main/java/com/bugsnag/android/StateEvent.kt b/app/src/main/java/com/bugsnag/android/StateEvent.kt new file mode 100644 index 0000000000..8e53a58bfb --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/StateEvent.kt @@ -0,0 +1,45 @@ +package com.bugsnag.android + +sealed class StateEvent { + class Install( + val apiKey: String, + val autoDetectNdkCrashes: Boolean, + val appVersion: String?, + val buildUuid: String?, + val releaseStage: String?, + val lastRunInfoPath: String, + val consecutiveLaunchCrashes: Int + ) : StateEvent() + + object DeliverPending : StateEvent() + + class AddMetadata(val section: String, val key: String?, val value: Any?) : StateEvent() + class ClearMetadataSection(val section: String) : StateEvent() + class ClearMetadataValue(val section: String, val key: String?) : StateEvent() + + class AddBreadcrumb( + val message: String, + val type: BreadcrumbType, + val timestamp: String, + val metadata: MutableMap + ) : StateEvent() + + object NotifyHandled : StateEvent() + object NotifyUnhandled : StateEvent() + + object PauseSession : StateEvent() + class StartSession( + val id: String, + val startedAt: String, + val handledCount: Int, + val unhandledCount: Int + ) : StateEvent() + + class UpdateContext(val context: String?) : StateEvent() + class UpdateInForeground(val inForeground: Boolean, val contextActivity: String?) : StateEvent() + class UpdateLastRunInfo(val consecutiveLaunchCrashes: Int) : StateEvent() + class UpdateIsLaunching(val isLaunching: Boolean) : StateEvent() + class UpdateOrientation(val orientation: String?) : StateEvent() + + class UpdateUser(val user: User) : StateEvent() +} diff --git a/app/src/main/java/com/bugsnag/android/StrictModeHandler.java b/app/src/main/java/com/bugsnag/android/StrictModeHandler.java new file mode 100644 index 0000000000..cc27508371 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/StrictModeHandler.java @@ -0,0 +1,98 @@ +package com.bugsnag.android; + +import android.annotation.SuppressLint; +import android.text.TextUtils; +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +class StrictModeHandler { + + // Byte 1: Thread-policy (needs to be synced with StrictMode constants) + private static final int DETECT_DISK_WRITE = 0x01; + private static final int DETECT_DISK_READ = 0x02; + private static final int DETECT_NETWORK = 0x04; + private static final int DETECT_CUSTOM = 0x08; + private static final int DETECT_RESOURCE_MISMATCH = 0x10; + + // Byte 2: Process-policy (needs to be synced with StrictMode constants) + private static final int DETECT_VM_CURSOR_LEAKS = 0x01 << 8; + private static final int DETECT_VM_CLOSABLE_LEAKS = 0x02 << 8; + private static final int DETECT_VM_ACTIVITY_LEAKS = 0x04 << 8; + private static final int DETECT_VM_INSTANCE_LEAKS = 0x08 << 8; + private static final int DETECT_VM_REGISTRATION_LEAKS = 0x10 << 8; + private static final int DETECT_VM_FILE_URI_EXPOSURE = 0x20 << 8; + private static final int DETECT_VM_CLEARTEXT_NETWORK = 0x40 << 8; + + + private static final String STRICT_MODE_CLZ_NAME = "android.os.strictmode"; + + @SuppressLint("UseSparseArrays") + private static final Map POLICY_CODE_MAP = new HashMap<>(); + + static { + POLICY_CODE_MAP.put(DETECT_DISK_WRITE, "DiskWrite"); + POLICY_CODE_MAP.put(DETECT_DISK_READ, "DiskRead"); + POLICY_CODE_MAP.put(DETECT_NETWORK, "NetworkOperation"); + POLICY_CODE_MAP.put(DETECT_CUSTOM, "CustomSlowCall"); + POLICY_CODE_MAP.put(DETECT_RESOURCE_MISMATCH, "ResourceMismatch"); + + POLICY_CODE_MAP.put(DETECT_VM_CURSOR_LEAKS, "CursorLeak"); + POLICY_CODE_MAP.put(DETECT_VM_CLOSABLE_LEAKS, "CloseableLeak"); + POLICY_CODE_MAP.put(DETECT_VM_ACTIVITY_LEAKS, "ActivityLeak"); + POLICY_CODE_MAP.put(DETECT_VM_INSTANCE_LEAKS, "InstanceLeak"); + POLICY_CODE_MAP.put(DETECT_VM_REGISTRATION_LEAKS, "RegistrationLeak"); + POLICY_CODE_MAP.put(DETECT_VM_FILE_URI_EXPOSURE, "FileUriLeak"); + POLICY_CODE_MAP.put(DETECT_VM_CLEARTEXT_NETWORK, "CleartextNetwork"); + } + + /** + * Checks whether a throwable was originally thrown from the StrictMode class + * + * @param throwable the throwable + * @return true if the throwable's root cause is a StrictMode policy violation + */ + boolean isStrictModeThrowable(Throwable throwable) { + Throwable cause = getRootCause(throwable); + Class causeClass = cause.getClass(); + String simpleName = causeClass.getName(); + return simpleName.toLowerCase(Locale.US).startsWith(STRICT_MODE_CLZ_NAME); + } + + @Nullable + String getViolationDescription(String exceptionMessage) { + if (TextUtils.isEmpty(exceptionMessage)) { + throw new IllegalArgumentException(); + } + int indexOf = exceptionMessage.lastIndexOf("violation="); + + if (indexOf != -1) { + String substring = exceptionMessage.substring(indexOf); + substring = substring.replace("violation=", ""); + + if (TextUtils.isDigitsOnly(substring)) { + Integer code = Integer.valueOf(substring); + return POLICY_CODE_MAP.get(code); + } + } + return null; + } + + /** + * Recurse the stack to get the original cause of the throwable + * + * @param throwable the throwable + * @return the root cause of the throwable + */ + private Throwable getRootCause(Throwable throwable) { + Throwable cause = throwable.getCause(); + + if (cause == null) { + return throwable; + } else { + return getRootCause(cause); + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/SynchronizedStreamableStore.kt b/app/src/main/java/com/bugsnag/android/SynchronizedStreamableStore.kt new file mode 100644 index 0000000000..66bf136e56 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SynchronizedStreamableStore.kt @@ -0,0 +1,39 @@ +package com.bugsnag.android + +import android.util.JsonReader +import java.io.File +import java.io.IOException +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.withLock + +/** + * Persists and loads a [Streamable] object to the file system. This is intended for use + * primarily as a replacement for primitive value stores such as [SharedPreferences]. + * + * This class is made thread safe through the use of a [ReadWriteLock]. + */ +internal class SynchronizedStreamableStore( + private val file: File +) { + + private val lock = ReentrantReadWriteLock() + + @Throws(IOException::class) + fun persist(streamable: T) { + lock.writeLock().withLock { + file.writer().buffered().use { + streamable.toStream(JsonStream(it)) + true + } + } + } + + @Throws(IOException::class) + fun load(loadCallback: (JsonReader) -> T): T { + lock.readLock().withLock { + return file.reader().buffered().use { + loadCallback(JsonReader(it)) + } + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/SystemBroadcastReceiver.java b/app/src/main/java/com/bugsnag/android/SystemBroadcastReceiver.java new file mode 100644 index 0000000000..0b0f9bc5cd --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/SystemBroadcastReceiver.java @@ -0,0 +1,191 @@ +package com.bugsnag.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + +/** + * Used to automatically create breadcrumbs for system events + * Broadcast actions and categories can be found in text files in the android folder + * e.g. ~/Library/Android/sdk/platforms/android-9/data/broadcast_actions.txt + * See http://stackoverflow.com/a/27601497 + */ +class SystemBroadcastReceiver extends BroadcastReceiver { + + private static final String INTENT_ACTION_KEY = "Intent Action"; + + private final Client client; + private final Logger logger; + private final Map actions; + + SystemBroadcastReceiver(@NonNull Client client, Logger logger) { + this.client = client; + this.logger = logger; + this.actions = buildActions(); + } + + static SystemBroadcastReceiver register(final Client client, + final Logger logger, + BackgroundTaskService bgTaskService) { + final SystemBroadcastReceiver receiver = new SystemBroadcastReceiver(client, logger); + if (receiver.getActions().size() > 0) { + try { + bgTaskService.submitTask(TaskType.DEFAULT, new Runnable() { + @Override + public void run() { + IntentFilter intentFilter = receiver.getIntentFilter(); + Context context = client.appContext; + ContextExtensionsKt.registerReceiverSafe(context, + receiver, intentFilter, logger); + } + }); + } catch (RejectedExecutionException ex) { + logger.w("Failed to register for automatic breadcrumb broadcasts", ex); + } + return receiver; + } else { + return null; + } + } + + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + try { + Map meta = new HashMap<>(); + String fullAction = intent.getAction(); + + if (fullAction == null) { + return; + } + + String shortAction = shortenActionNameIfNeeded(fullAction); + meta.put(INTENT_ACTION_KEY, fullAction); // always add the Intent Action + + Bundle extras = intent.getExtras(); + if (extras != null) { + for (String key : extras.keySet()) { + Object valObj = extras.get(key); + if (valObj == null) { + continue; + } + + String val = valObj.toString(); + + if (isAndroidKey(key)) { // shorten the Intent action + meta.put("Extra", String.format("%s: %s", shortAction, val)); + } else { + meta.put(key, val); + } + } + } + BreadcrumbType type = actions.get(fullAction); + + if (type == null) { + type = BreadcrumbType.STATE; + } + client.leaveBreadcrumb(shortAction, meta, type); + + } catch (Exception ex) { + logger.w("Failed to leave breadcrumb in SystemBroadcastReceiver: " + + ex.getMessage()); + } + } + + private static boolean isAndroidKey(@NonNull String actionName) { + return actionName.startsWith("android."); + } + + @NonNull + static String shortenActionNameIfNeeded(@NonNull String action) { + if (isAndroidKey(action)) { + return action.substring(action.lastIndexOf(".") + 1); + } else { + return action; + } + } + + /** + * Builds a map of intent actions and their breadcrumb type (if enabled). + * + * Noisy breadcrumbs are omitted, along with anything that involves a state change. + * @return the action map + */ + @NonNull + private Map buildActions() { + + Map actions = new HashMap<>(); + if (client.getConfig().shouldRecordBreadcrumbType(BreadcrumbType.USER)) { + actions.put("android.appwidget.action.APPWIDGET_DELETED", BreadcrumbType.USER); + actions.put("android.appwidget.action.APPWIDGET_DISABLED", BreadcrumbType.USER); + actions.put("android.appwidget.action.APPWIDGET_ENABLED", BreadcrumbType.USER); + actions.put("android.intent.action.CAMERA_BUTTON", BreadcrumbType.USER); + actions.put("android.intent.action.CLOSE_SYSTEM_DIALOGS", BreadcrumbType.USER); + actions.put("android.intent.action.DOCK_EVENT", BreadcrumbType.USER); + } + + if (client.getConfig().shouldRecordBreadcrumbType(BreadcrumbType.STATE)) { + actions.put("android.appwidget.action.APPWIDGET_HOST_RESTORED", BreadcrumbType.STATE); + actions.put("android.appwidget.action.APPWIDGET_RESTORED", BreadcrumbType.STATE); + actions.put("android.appwidget.action.APPWIDGET_UPDATE", BreadcrumbType.STATE); + actions.put("android.appwidget.action.APPWIDGET_UPDATE_OPTIONS", BreadcrumbType.STATE); + actions.put("android.intent.action.ACTION_POWER_CONNECTED", BreadcrumbType.STATE); + actions.put("android.intent.action.ACTION_POWER_DISCONNECTED", BreadcrumbType.STATE); + actions.put("android.intent.action.ACTION_SHUTDOWN", BreadcrumbType.STATE); + actions.put("android.intent.action.AIRPLANE_MODE", BreadcrumbType.STATE); + actions.put("android.intent.action.BATTERY_LOW", BreadcrumbType.STATE); + actions.put("android.intent.action.BATTERY_OKAY", BreadcrumbType.STATE); + actions.put("android.intent.action.BOOT_COMPLETED", BreadcrumbType.STATE); + actions.put("android.intent.action.CONFIGURATION_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.CONTENT_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.DATE_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.DEVICE_STORAGE_LOW", BreadcrumbType.STATE); + actions.put("android.intent.action.DEVICE_STORAGE_OK", BreadcrumbType.STATE); + actions.put("android.intent.action.INPUT_METHOD_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.LOCALE_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.REBOOT", BreadcrumbType.STATE); + actions.put("android.intent.action.SCREEN_OFF", BreadcrumbType.STATE); + actions.put("android.intent.action.SCREEN_ON", BreadcrumbType.STATE); + actions.put("android.intent.action.TIMEZONE_CHANGED", BreadcrumbType.STATE); + actions.put("android.intent.action.TIME_SET", BreadcrumbType.STATE); + actions.put("android.os.action.DEVICE_IDLE_MODE_CHANGED", BreadcrumbType.STATE); + actions.put("android.os.action.POWER_SAVE_MODE_CHANGED", BreadcrumbType.STATE); + } + + if (client.getConfig().shouldRecordBreadcrumbType(BreadcrumbType.NAVIGATION)) { + actions.put("android.intent.action.DREAMING_STARTED", BreadcrumbType.NAVIGATION); + actions.put("android.intent.action.DREAMING_STOPPED", BreadcrumbType.NAVIGATION); + } + + return actions; + } + + /** + * @return the enabled actions + */ + public Map getActions() { + return actions; + } + + /** + * Creates a new Intent filter with all the intents to record breadcrumbs for + * + * @return The intent filter + */ + @NonNull + public IntentFilter getIntentFilter() { + IntentFilter filter = new IntentFilter(); + + for (String action : actions.keySet()) { + filter.addAction(action); + } + return filter; + } + +} diff --git a/app/src/main/java/com/bugsnag/android/Thread.java b/app/src/main/java/com/bugsnag/android/Thread.java new file mode 100644 index 0000000000..90daa50e49 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/Thread.java @@ -0,0 +1,114 @@ +package com.bugsnag.android; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.util.List; + +/** + * A representation of a thread recorded in an {@link Event} + */ +@SuppressWarnings("ConstantConditions") +public class Thread implements JsonStream.Streamable { + + private final ThreadInternal impl; + private final Logger logger; + + Thread( + long id, + @NonNull String name, + @NonNull ThreadType type, + boolean errorReportingThread, + @NonNull Stacktrace stacktrace, + @NonNull Logger logger) { + this.impl = new ThreadInternal(id, name, type, errorReportingThread, stacktrace); + this.logger = logger; + } + + private void logNull(String property) { + logger.e("Invalid null value supplied to thread." + property + ", ignoring"); + } + + /** + * Sets the unique ID of the thread (from {@link java.lang.Thread}) + */ + public void setId(long id) { + impl.setId(id); + } + + /** + * Gets the unique ID of the thread (from {@link java.lang.Thread}) + */ + public long getId() { + return impl.getId(); + } + + /** + * Sets the name of the thread (from {@link java.lang.Thread}) + */ + public void setName(@NonNull String name) { + if (name != null) { + impl.setName(name); + } else { + logNull("name"); + } + } + + /** + * Gets the name of the thread (from {@link java.lang.Thread}) + */ + @NonNull + public String getName() { + return impl.getName(); + } + + /** + * Sets the type of thread based on the originating platform (intended for internal use only) + */ + public void setType(@NonNull ThreadType type) { + if (type != null) { + impl.setType(type); + } else { + logNull("type"); + } + } + + /** + * Gets the type of thread based on the originating platform (intended for internal use only) + */ + @NonNull + public ThreadType getType() { + return impl.getType(); + } + + /** + * Gets whether the thread was the thread that caused the event + */ + public boolean getErrorReportingThread() { + return impl.isErrorReportingThread(); + } + + /** + * Sets a representation of the thread's stacktrace + */ + public void setStacktrace(@NonNull List stacktrace) { + if (!CollectionUtils.containsNullElements(stacktrace)) { + impl.setStacktrace(stacktrace); + } else { + logNull("stacktrace"); + } + } + + /** + * Gets a representation of the thread's stacktrace + */ + @NonNull + public List getStacktrace() { + return impl.getStacktrace(); + } + + @Override + public void toStream(@NonNull JsonStream stream) throws IOException { + impl.toStream(stream); + } +} diff --git a/app/src/main/java/com/bugsnag/android/ThreadInternal.kt b/app/src/main/java/com/bugsnag/android/ThreadInternal.kt new file mode 100644 index 0000000000..459c17f87e --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ThreadInternal.kt @@ -0,0 +1,32 @@ +package com.bugsnag.android + +import java.io.IOException + +class ThreadInternal internal constructor( + var id: Long, + var name: String, + var type: ThreadType, + val isErrorReportingThread: Boolean, + stacktrace: Stacktrace +) : JsonStream.Streamable { + + var stacktrace: MutableList = stacktrace.trace.toMutableList() + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name("id").value(id) + writer.name("name").value(name) + writer.name("type").value(type.desc) + + writer.name("stacktrace") + writer.beginArray() + stacktrace.forEach { writer.value(it) } + writer.endArray() + + if (isErrorReportingThread) { + writer.name("errorReportingThread").value(true) + } + writer.endObject() + } +} diff --git a/app/src/main/java/com/bugsnag/android/ThreadSendPolicy.kt b/app/src/main/java/com/bugsnag/android/ThreadSendPolicy.kt new file mode 100644 index 0000000000..beb9bee0dd --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ThreadSendPolicy.kt @@ -0,0 +1,27 @@ +package com.bugsnag.android + +/** + * Controls whether we should capture and serialize the state of all threads at the time + * of an error. + */ +enum class ThreadSendPolicy { + + /** + * Threads should be captured for all events. + */ + ALWAYS, + + /** + * Threads should be captured for unhandled events only. + */ + UNHANDLED_ONLY, + + /** + * Threads should never be captured. + */ + NEVER; + + internal companion object { + fun fromString(str: String) = values().find { it.name == str } ?: ALWAYS + } +} diff --git a/app/src/main/java/com/bugsnag/android/ThreadState.kt b/app/src/main/java/com/bugsnag/android/ThreadState.kt new file mode 100644 index 0000000000..739a6b7a2f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ThreadState.kt @@ -0,0 +1,84 @@ +package com.bugsnag.android + +import java.io.IOException + +/** + * Capture and serialize the state of all threads at the time of an exception. + */ +internal class ThreadState @JvmOverloads constructor( + exc: Throwable?, + isUnhandled: Boolean, + sendThreads: ThreadSendPolicy, + projectPackages: Collection, + logger: Logger, + currentThread: java.lang.Thread = java.lang.Thread.currentThread(), + stackTraces: MutableMap> = java.lang.Thread.getAllStackTraces() +) : JsonStream.Streamable { + + internal constructor( + exc: Throwable?, + isUnhandled: Boolean, + config: ImmutableConfig + ) : this(exc, isUnhandled, config.sendThreads, config.projectPackages, config.logger) + + val threads: MutableList + + init { + val recordThreads = sendThreads == ThreadSendPolicy.ALWAYS || + (sendThreads == ThreadSendPolicy.UNHANDLED_ONLY && isUnhandled) + + threads = when { + recordThreads -> captureThreadTrace( + stackTraces, + currentThread, + exc, + isUnhandled, + projectPackages, + logger + ) + else -> mutableListOf() + } + } + + private fun captureThreadTrace( + stackTraces: MutableMap>, + currentThread: java.lang.Thread, + exc: Throwable?, + isUnhandled: Boolean, + projectPackages: Collection, + logger: Logger + ): MutableList { + // API 24/25 don't record the currentThread, add it in manually + // https://issuetracker.google.com/issues/64122757 + if (!stackTraces.containsKey(currentThread)) { + stackTraces[currentThread] = currentThread.stackTrace + } + if (exc != null && isUnhandled) { // unhandled errors use the exception trace for thread traces + stackTraces[currentThread] = exc.stackTrace + } + + val currentThreadId = currentThread.id + return stackTraces.keys + .sortedBy { it.id } + .mapNotNull { thread -> + val trace = stackTraces[thread] + + if (trace != null) { + val stacktrace = Stacktrace.stacktraceFromJavaTrace(trace, projectPackages, logger) + val errorThread = thread.id == currentThreadId + Thread(thread.id, thread.name, ThreadType.ANDROID, errorThread, stacktrace, logger) + } else { + null + } + }.toMutableList() + } + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginArray() + for (thread in threads) { + writer.value(thread) + } + writer.endArray() + } +} diff --git a/app/src/main/java/com/bugsnag/android/ThreadType.kt b/app/src/main/java/com/bugsnag/android/ThreadType.kt new file mode 100644 index 0000000000..2769447f4d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/ThreadType.kt @@ -0,0 +1,22 @@ +package com.bugsnag.android + +/** + * Represents the type of thread captured + */ +enum class ThreadType(internal val desc: String) { + + /** + * A thread captured from Android's JVM layer + */ + ANDROID("android"), + + /** + * A thread captured from Android's NDK layer + */ + C("c"), + + /** + * A thread captured from JavaScript + */ + REACTNATIVEJS("reactnativejs") +} diff --git a/app/src/main/java/com/bugsnag/android/User.kt b/app/src/main/java/com/bugsnag/android/User.kt new file mode 100644 index 0000000000..a2b2fd3267 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/User.kt @@ -0,0 +1,83 @@ +package com.bugsnag.android + +import android.util.JsonReader +import java.io.IOException + +/** + * Information about the current user of your application. + */ +class User @JvmOverloads internal constructor( + /** + * @return the user ID, by default a UUID generated on installation + */ + val id: String? = null, + + /** + * @return the user's email, if available + */ + val email: String? = null, + + /** + * @return the user's name, if available + */ + val name: String? = null +) : JsonStream.Streamable { + + @Throws(IOException::class) + override fun toStream(writer: JsonStream) { + writer.beginObject() + writer.name(KEY_ID).value(id) + writer.name(KEY_EMAIL).value(email) + writer.name(KEY_NAME).value(name) + writer.endObject() + } + + internal companion object : JsonReadable { + private const val KEY_ID = "id" + private const val KEY_NAME = "name" + private const val KEY_EMAIL = "email" + + override fun fromReader(reader: JsonReader): User { + var user: User + with(reader) { + beginObject() + var id: String? = null + var email: String? = null + var name: String? = null + + while (hasNext()) { + val key = nextName() + val value = nextString() + when (key) { + KEY_ID -> id = value + KEY_EMAIL -> email = value + KEY_NAME -> name = value + } + } + user = User(id, email, name) + endObject() + } + return user + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as User + + if (id != other.id) return false + if (email != other.email) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + (email?.hashCode() ?: 0) + result = 31 * result + (name?.hashCode() ?: 0) + return result + } +} diff --git a/app/src/main/java/com/bugsnag/android/UserAware.kt b/app/src/main/java/com/bugsnag/android/UserAware.kt new file mode 100644 index 0000000000..3798d2a074 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/UserAware.kt @@ -0,0 +1,6 @@ +package com.bugsnag.android + +internal interface UserAware { + fun getUser(): User + fun setUser(id: String?, email: String?, name: String?) +} diff --git a/app/src/main/java/com/bugsnag/android/UserState.kt b/app/src/main/java/com/bugsnag/android/UserState.kt new file mode 100644 index 0000000000..8f408b7156 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/UserState.kt @@ -0,0 +1,11 @@ +package com.bugsnag.android + +internal class UserState(user: User) : BaseObservable() { + var user = user + set(value) { + field = value + emitObservableEvent() + } + + fun emitObservableEvent() = notifyObservers(StateEvent.UpdateUser(user)) +} diff --git a/app/src/main/java/com/bugsnag/android/UserStore.kt b/app/src/main/java/com/bugsnag/android/UserStore.kt new file mode 100644 index 0000000000..30540ca41c --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/UserStore.kt @@ -0,0 +1,97 @@ +package com.bugsnag.android + +import java.io.File +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +/** + * This class is responsible for persisting and retrieving user information. + */ +internal class UserStore @JvmOverloads constructor( + private val config: ImmutableConfig, + private val deviceId: String?, + file: File = File(config.persistenceDirectory, "user-info"), + private val sharedPrefMigrator: SharedPrefMigrator, + private val logger: Logger +) { + + private val synchronizedStreamableStore: SynchronizedStreamableStore + private val persist = config.persistUser + private val previousUser = AtomicReference(null) + + init { + try { + if (!file.exists()) { + file.createNewFile() + } + } catch (exc: IOException) { + logger.w("Failed to created device ID file", exc) + } + this.synchronizedStreamableStore = SynchronizedStreamableStore(file) + } + + /** + * Loads the user state which should be used by the [Client]. This is supplied either from + * the [Configuration] value, or a file in the [Configuration.getPersistenceDirectory] if + * [Configuration.getPersistUser] is true. + * + * If no user is stored on disk, then a default [User] is used which uses the device ID + * as its ID. + * + * The [UserState] provides a mechanism for observing value changes to its user property, + * so to avoid interfering with this the method should only be called once for each [Client]. + */ + fun load(initialUser: User): UserState { + val validConfigUser = validUser(initialUser) + + val loadedUser = when { + validConfigUser -> initialUser + persist -> loadPersistedUser() + else -> null + } + + val userState = when { + loadedUser != null && validUser(loadedUser) -> UserState(loadedUser) + else -> UserState(User(deviceId, null, null)) + } + + userState.addObserver { _, arg -> + if (arg is StateEvent.UpdateUser) { + save(arg.user) + } + } + return userState + } + + /** + * Persists the user if [Configuration.getPersistUser] is true and the object is different + * from the previously persisted value. + */ + fun save(user: User) { + if (persist && user != previousUser.getAndSet(user)) { + try { + synchronizedStreamableStore.persist(user) + } catch (exc: Exception) { + logger.w("Failed to persist user info", exc) + } + } + } + + private fun validUser(user: User) = + user.id != null || user.name != null || user.email != null + + private fun loadPersistedUser(): User? { + return if (sharedPrefMigrator.hasPrefs()) { + val legacyUser = sharedPrefMigrator.loadUser(deviceId) + save(legacyUser) + legacyUser + } else { + return try { + synchronizedStreamableStore.load(User.Companion::fromReader) + } catch (exc: Exception) { + logger.w("Failed to load user info", exc) + null + } + } + } +} diff --git a/build.gradle b/build.gradle index a2e6623618..d17ed669d9 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,9 @@ buildscript { classpath 'com.android.tools.build:gradle:4.2.1' // https://github.com/bugsnag/bugsnag-android-gradle-plugin classpath 'com.bugsnag:bugsnag-android-gradle-plugin:5.7.6' + // https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-gradle-plugin + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.0" + classpath "org.jetbrains.kotlin:kotlin-android-extensions:1.5.0" } }