diff --git a/components/lib/crash/build.gradle b/components/lib/crash/build.gradle index 9242bfde865..c85fd576a92 100644 --- a/components/lib/crash/build.gradle +++ b/components/lib/crash/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation Dependencies.androidx_appcompat implementation Dependencies.androidx_constraintlayout + implementation Dependencies.androidx_recyclerview implementation project(':support-base') implementation project(':support-ktx') diff --git a/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json b/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json index c227e04c2f4..5198e55f44c 100644 --- a/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json +++ b/components/lib/crash/schemas/mozilla.components.lib.crash.db.CrashDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "c82f6a8cf8fbd6509f73a7d7d5ff97cd", + "identityHash": "212dfa0b59d6a78d81e65cead34d40e0", "entities": [ { "tableName": "crashes", @@ -38,7 +38,7 @@ }, { "tableName": "reports", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `crashUuid` TEXT NOT NULL, `service_id` TEXT NOT NULL, `report_id` TEXT NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `crash_uuid` TEXT NOT NULL, `service_id` TEXT NOT NULL, `report_id` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", @@ -48,7 +48,7 @@ }, { "fieldPath": "crashUuid", - "columnName": "crashUuid", + "columnName": "crash_uuid", "affinity": "TEXT", "notNull": true }, @@ -78,7 +78,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c82f6a8cf8fbd6509f73a7d7d5ff97cd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '212dfa0b59d6a78d81e65cead34d40e0')" ] } } \ No newline at end of file diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt index e5d8f829d04..fcbcd5a2bee 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/CrashReporter.kt @@ -15,6 +15,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import mozilla.components.lib.crash.db.CrashDatabase +import mozilla.components.lib.crash.db.insertCrashSafely +import mozilla.components.lib.crash.db.insertReportSafely +import mozilla.components.lib.crash.db.toEntity +import mozilla.components.lib.crash.db.toReportEntity import mozilla.components.lib.crash.handler.ExceptionHandler import mozilla.components.lib.crash.notification.CrashNotification import mozilla.components.lib.crash.prompt.CrashPrompt @@ -55,6 +60,7 @@ import mozilla.components.support.base.log.logger.Logger */ @Suppress("TooManyFunctions") class CrashReporter( + context: Context, private val services: List = emptyList(), private val telemetryServices: List = emptyList(), private val shouldPrompt: Prompt = Prompt.NEVER, @@ -63,6 +69,8 @@ class CrashReporter( private val nonFatalCrashIntent: PendingIntent? = null, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) ) : CrashReporting { + private val database: CrashDatabase by lazy { CrashDatabase.get(context) } + internal val logger = Logger("mozac/CrashReporter") internal val crashBreadcrumbs = BreadcrumbPriorityQueue(BREADCRUMB_MAX_NUM) @@ -91,10 +99,14 @@ class CrashReporter( fun submitReport(crash: Crash, then: () -> Unit = {}): Job { return scope.launch { services.forEach { service -> - when (crash) { + val reportId = when (crash) { is Crash.NativeCodeCrash -> service.report(crash) is Crash.UncaughtExceptionCrash -> service.report(crash) } + + if (reportId != null) { + database.crashDao().insertReportSafely(service.toReportEntity(crash, reportId)) + } } logger.info("Crash report submitted to ${services.size} services") @@ -156,6 +168,8 @@ class CrashReporter( logger.info("Received crash: $crash") + database.crashDao().insertCrashSafely(crash.toEntity()) + if (telemetryServices.isNotEmpty()) { sendCrashTelemetry(context, crash) } @@ -230,6 +244,10 @@ class CrashReporter( } } + internal fun getCrashReporterServiceById(id: String): CrashReporterService { + return services.first { it.id == id } + } + enum class Prompt { /** * Never prompt the user. Always submit crash reports immediately. diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt index b87cbb625f5..66620efa8a6 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/CrashDao.kt @@ -4,11 +4,14 @@ package mozilla.components.lib.crash.db +import android.annotation.SuppressLint +import android.util.Log import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction +import java.lang.Exception /** * Dao for saving and accessing crash related information. @@ -34,3 +37,35 @@ internal interface CrashDao { @Query("SELECT * FROM crashes ORDER BY created_at DESC") fun getCrashesWithReports(): LiveData> } + +/** + * Insert crash into database safely, ignoring any exceptions. + * + * When handling a crash we want to avoid causing another crash when writing to the database. In the + * case of an error we will just ignore it and continue without saving to the database. + */ +@SuppressLint("LogUsage") // We do not want to use our custom logger while handling the crash +@Suppress("TooGenericExceptionCaught") +internal fun CrashDao.insertCrashSafely(entity: CrashEntity) { + try { + insertCrash(entity) + } catch (e: Exception) { + Log.e("CrashDao", "Failed to insert crash into database", e) + } +} + +/** + * Insert report into database safely, ignoring any exceptions. + * + * When handling a crash we want to avoid causing another crash when writing to the database. In the + * case of an error we will just ignore it and continue without saving to the database. + */ +@SuppressLint("LogUsage") // We do not want to use our custom logger while handling the crash +@Suppress("TooGenericExceptionCaught") +internal fun CrashDao.insertReportSafely(entity: ReportEntity) { + try { + insertReport(entity) + } catch (e: Exception) { + Log.e("CrashDao", "Failed to insert report into database", e) + } +} diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt index b2e4e2f832f..0769368fbd9 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/db/ReportEntity.kt @@ -7,6 +7,7 @@ package mozilla.components.lib.crash.db import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import mozilla.components.lib.crash.Crash import mozilla.components.lib.crash.service.CrashReporterService /** @@ -41,3 +42,11 @@ internal data class ReportEntity( @ColumnInfo(name = "report_id") var reportId: String ) + +internal fun CrashReporterService.toReportEntity(crash: Crash, reportId: String): ReportEntity { + return ReportEntity( + crashUuid = crash.uuid, + serviceId = id, + reportId = reportId + ) +} diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt index b167b5db122..4c1632be321 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/handler/ExceptionHandler.kt @@ -26,8 +26,14 @@ class ExceptionHandler( try { crashing = true - crashReporter.onCrash(context, Crash.UncaughtExceptionCrash(throwable, - crashReporter.crashBreadcrumbs.toSortedArrayList())) + + crashReporter.onCrash( + context, + Crash.UncaughtExceptionCrash( + throwable = throwable, + breadcrumbs = crashReporter.crashBreadcrumbs.toSortedArrayList() + ) + ) defaultExceptionHandler?.uncaughtException(thread, throwable) } finally { diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt index 192d86da491..436eff5b104 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/CrashReporterService.kt @@ -13,27 +13,42 @@ internal const val INFO_PREFIX = "[INFO]" * Interface to be implemented by external services that accept crash reports. */ interface CrashReporterService { + /** + * A unique ID to identify this crash reporter service. + */ + val id: String + + /** + * A human-readable name for this crash reporter service (to be displayed in UI). + */ + val name: String + + /** + * Returns a URL to a website with the crash report if possible. Otherwise returns null. + */ + fun createCrashReportUrl(identifier: String): String? + /** * Submits a crash report for this [Crash.UncaughtExceptionCrash]. * - * @return Unique identifier that can be used by/with this crash reporter service to find this - * crash - or null if no identifier can be provided. + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. */ fun report(crash: Crash.UncaughtExceptionCrash): String? /** * Submits a crash report for this [Crash.NativeCodeCrash]. * - * @return Unique identifier that can be used by/with this crash reporter service to find this - * crash - or null if no identifier can be provided. + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. */ fun report(crash: Crash.NativeCodeCrash): String? /** * Submits a caught exception report for this [Throwable]. * - * @return Unique identifier that can be used by/with this crash reporter service to find this - * crash - or null if no identifier can be provided. + * @return Unique crash report identifier that can be used by/with this crash reporter service + * to find this reported crash - or null if no identifier can be provided. */ fun report(throwable: Throwable, breadcrumbs: ArrayList): String? } diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt index 238940ae415..249989a07f1 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/MozillaSocorroService.kt @@ -77,6 +77,14 @@ class MozillaSocorroService( private val logger = Logger("mozac/MozillaSocorroCrashHelperService") private val startTime = System.currentTimeMillis() + override val id: String = "socorro" + + override val name: String = "Socorro" + + override fun createCrashReportUrl(identifier: String): String? { + return "https://crash-stats.mozilla.org/report/index/$identifier" + } + init { if (versionName == DEFAULT_VERSION_NAME) { try { @@ -161,6 +169,7 @@ class MozillaSocorroService( val map = parseResponse(reader) val id = map?.get(KEY_CRASH_ID) + if (id != null) { logger.info("Crash reported to Socorro: $id") } else { diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SentryService.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SentryService.kt index 9645721dfcc..874b7b64627 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SentryService.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SentryService.kt @@ -39,6 +39,14 @@ class SentryService( private val sendEventForNativeCrashes: Boolean = false, clientFactory: SentryClientFactory? = null ) : CrashReporterService { + override val id: String = "sentry" + + override val name: String = "Sentry" + + override fun createCrashReportUrl(identifier: String): String? { + val id = identifier.replace("-", "") + return "https://sentry.prod.mozaws.net/operations/samples-crash/?query=$id" + } // Fenix perf note: Sentry init may negatively impact cold startup so it's important this is lazily init. @VisibleForTesting(otherwise = PRIVATE) @@ -73,6 +81,7 @@ class SentryService( val eventBuilder = EventBuilder().withMessage(createMessage(crash)) .withLevel(Event.Level.FATAL) .withSentryInterface(ExceptionInterface(crash.throwable)) + client.sendEvent(eventBuilder) client.context.clearBreadcrumbs() diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt new file mode 100644 index 00000000000..9fd7c6bc81a --- /dev/null +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/AbstractCrashListActivity.kt @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R + +/** + * Activity for displaying the list of reported crashes. + */ +abstract class AbstractCrashListActivity : AppCompatActivity() { + abstract val crashReporter: CrashReporter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.mozac_lib_crash_activity_title) + setContentView(R.layout.mozac_lib_crash_activity_crashlist) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .add(R.id.container, CrashListFragment()) + .commit() + } + } + + /** + * Gets invoked whenever the user selects a crash reporting service. + * + * @param url URL pointing to the crash report for the selected crash reporting service. + */ + abstract fun onCrashServiceSelected(url: String) +} diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt new file mode 100644 index 00000000000..498ee7ee86e --- /dev/null +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListAdapter.kt @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.format.DateUtils +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.db.CrashWithReports +import mozilla.components.lib.crash.db.ReportEntity + +/** + * RecyclerView adapter for displaying the list of crashes. + */ +internal class CrashListAdapter( + private val crashReporter: CrashReporter, + private val onSelection: (String) -> Unit +) : RecyclerView.Adapter() { + private var crashes: List = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CrashViewHolder { + val view = LayoutInflater.from( + parent.context + ).inflate( + R.layout.mozac_lib_crash_item_crash, + parent, + false + ) + + return CrashViewHolder(view) + } + + override fun getItemCount(): Int { + return crashes.size + } + + override fun onBindViewHolder(holder: CrashViewHolder, position: Int) { + val crashWithReports = crashes[position] + + holder.idView.text = crashWithReports.crash.uuid + + holder.titleView.text = crashWithReports.crash.stacktrace.lines().first() + + val time = DateUtils.getRelativeDateTimeString( + holder.footerView.context, + crashWithReports.crash.createdAt, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0 + ) + + holder.footerView.text = SpannableStringBuilder(time).apply { + if (crashWithReports.reports.isNotEmpty()) { + append(" - ") + append(crashReporter, crashWithReports.reports, onSelection) + } + } + } + + fun updateList(list: List) { + crashes = list + notifyDataSetChanged() + } +} + +internal class CrashViewHolder( + view: View +) : RecyclerView.ViewHolder( + view +) { + val titleView = view.findViewById(R.id.mozac_lib_crash_title) + val idView = view.findViewById(R.id.mozac_lib_crash_id) + val footerView = view.findViewById(R.id.mozac_lib_crash_footer).apply { + movementMethod = LinkMovementMethod.getInstance() + } +} + +private fun SpannableStringBuilder.append( + crashReporter: CrashReporter, + services: List, + onSelection: (String) -> Unit +): SpannableStringBuilder { + services.forEachIndexed { index, entity -> + val name = crashReporter.getCrashReporterServiceById(entity.serviceId).name + val url = crashReporter.getCrashReporterServiceById(entity.serviceId) + .createCrashReportUrl(entity.reportId) + + if (url != null) { + append(name, object : ClickableSpan() { + override fun onClick(widget: View) { + onSelection(url) + } + }, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } else { + append(name) + } + + if (index < services.lastIndex) { + append(" ") + } + } + return this +} diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt new file mode 100644 index 00000000000..ba44ca72bdc --- /dev/null +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/ui/CrashListFragment.kt @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.crash.ui + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.db.CrashDatabase + +/** + * Fragment displaying the list of crashes. + */ +internal class CrashListFragment : Fragment(R.layout.mozac_lib_crash_crashlist) { + private val database by lazy { CrashDatabase.get(requireContext()) } + private val reporter by lazy { (activity as AbstractCrashListActivity).crashReporter } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val listView: RecyclerView = view.findViewById(R.id.mozac_lib_crash_list) + listView.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.VERTICAL, + false + ) + + val emptyView = view.findViewById(R.id.mozac_lib_crash_empty) + + val adapter = CrashListAdapter(reporter, ::onSelection) + listView.adapter = adapter + + val dividerItemDecoration = DividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL + ) + listView.addItemDecoration(dividerItemDecoration) + + database.crashDao().getCrashesWithReports().observe(viewLifecycleOwner, Observer { list -> + if (list.isEmpty()) { + emptyView.visibility = View.VISIBLE + } else { + adapter.updateList(list) + } + }) + } + + private fun onSelection(url: String) { + (activity!! as AbstractCrashListActivity).onCrashServiceSelected(url) + } +} diff --git a/components/lib/crash/src/main/res/layout/mozac_lib_crash_activity_crashlist.xml b/components/lib/crash/src/main/res/layout/mozac_lib_crash_activity_crashlist.xml new file mode 100644 index 00000000000..15135198f66 --- /dev/null +++ b/components/lib/crash/src/main/res/layout/mozac_lib_crash_activity_crashlist.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml b/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml new file mode 100644 index 00000000000..482f4593756 --- /dev/null +++ b/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashlist.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml b/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml index fdd32998aef..ee3fde12c23 100644 --- a/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml +++ b/components/lib/crash/src/main/res/layout/mozac_lib_crash_crashreporter.xml @@ -1,4 +1,5 @@ - + + + + + + + + + + diff --git a/components/lib/crash/src/main/res/values/strings.xml b/components/lib/crash/src/main/res/values/strings.xml index b98fa27148f..4a883c4e925 100644 --- a/components/lib/crash/src/main/res/values/strings.xml +++ b/components/lib/crash/src/main/res/values/strings.xml @@ -23,4 +23,10 @@ Sending crash report to %1$s + + + Crash Reports + + + No crash reports have been submitted. diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt index 2e471e08877..84913b2e363 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/BreadcrumbTest.kt @@ -35,8 +35,9 @@ class BreadcrumbTest { val testType = Breadcrumb.Type.USER val reporter = spy(CrashReporter( - services = listOf(mock()), - shouldPrompt = CrashReporter.Prompt.NEVER + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) reporter.recordCrashBreadcrumb( @@ -60,8 +61,9 @@ class BreadcrumbTest { val testType = Breadcrumb.Type.USER val reporter = spy(CrashReporter( - services = listOf(mock()), - shouldPrompt = CrashReporter.Prompt.NEVER + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) reporter.recordCrashBreadcrumb( @@ -89,8 +91,9 @@ class BreadcrumbTest { val testType = Breadcrumb.Type.USER val reporter = spy(CrashReporter( - services = listOf(mock()), - shouldPrompt = CrashReporter.Prompt.NEVER + context = testContext, + services = listOf(mock()), + shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) val beginDate = Date() diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt index 339015f70ec..3506e208766 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/CrashReporterTest.kt @@ -56,6 +56,7 @@ class CrashReporterTest { val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() CrashReporter( + context = testContext, services = listOf(mock()) ).install(testContext) @@ -67,8 +68,10 @@ class CrashReporterTest { @Test(expected = IllegalArgumentException::class) fun `CrashReporter throws if no service is defined`() { - CrashReporter(emptyList()) - .install(testContext) + CrashReporter( + context = testContext, + services = emptyList() + ).install(testContext) } @Test @@ -77,13 +80,14 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.NEVER, scope = scope ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -98,13 +102,14 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.ALWAYS, scope = scope ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -119,13 +124,14 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, scope = scope ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -140,6 +146,7 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH, @@ -167,12 +174,13 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.ALWAYS ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -186,11 +194,12 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.ALWAYS ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -204,11 +213,12 @@ class CrashReporterTest { val service: CrashReporterService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.ALWAYS ).install(testContext)) - val crash: Crash.UncaughtExceptionCrash = mock() + val crash: Crash.UncaughtExceptionCrash = createUncaughtExceptionCrash() reporter.onCrash(testContext, crash) @@ -222,6 +232,7 @@ class CrashReporterTest { try { CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS ).install(testContext) } catch (e: IllegalArgumentException) { @@ -237,6 +248,7 @@ class CrashReporterTest { try { CrashReporter( + context = testContext, services = listOf(mock()) ).install(testContext) } catch (e: IllegalArgumentException) { @@ -246,6 +258,7 @@ class CrashReporterTest { try { CrashReporter( + context = testContext, telemetryServices = listOf(mock()) ).install(testContext) } catch (e: IllegalArgumentException) { @@ -257,6 +270,7 @@ class CrashReporterTest { @Test fun `CrashReporter is enabled by default`() { val reporter = spy(CrashReporter( + context = testContext, services = listOf(mock()), shouldPrompt = CrashReporter.Prompt.ONLY_NATIVE_CRASH ).install(testContext)) @@ -269,6 +283,7 @@ class CrashReporterTest { val service: CrashReporterService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.ALWAYS, scope = scope @@ -287,10 +302,12 @@ class CrashReporterTest { } @Test - fun `CrashReporter submits crashes`() { - val crash = mock() + fun `CrashReporter sends telemetry`() { + val crash = createUncaughtExceptionCrash() + val service = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.NEVER, scope = scope @@ -305,6 +322,12 @@ class CrashReporterTest { var exceptionCrash = false val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + override fun report(crash: Crash.UncaughtExceptionCrash): String? { exceptionCrash = true return null @@ -316,6 +339,7 @@ class CrashReporterTest { } val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) @@ -331,6 +355,12 @@ class CrashReporterTest { var nativeCrash = false val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null override fun report(crash: Crash.NativeCodeCrash): String? { @@ -342,6 +372,7 @@ class CrashReporterTest { } val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) @@ -363,6 +394,12 @@ class CrashReporterTest { var exceptionThrowable: Throwable? = null var exceptionBreadcrumb: ArrayList? = null val service = object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null override fun report(crash: Crash.NativeCodeCrash): String? = null @@ -376,6 +413,7 @@ class CrashReporterTest { } val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) @@ -406,6 +444,7 @@ class CrashReporterTest { } val reporter = spy(CrashReporter( + context = testContext, telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) @@ -423,6 +462,7 @@ class CrashReporterTest { } val reporter = CrashReporter( + context = testContext, services = listOf(mock()) ) @@ -443,6 +483,7 @@ class CrashReporterTest { val pendingIntent = spy(PendingIntent.getActivity(context, 0, intent, 0)) val reporter = CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS, services = listOf(mock()), nonFatalCrashIntent = pendingIntent @@ -476,6 +517,7 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.NEVER, @@ -502,6 +544,7 @@ class CrashReporterTest { val telemetryService: CrashTelemetryService = mock() val reporter = spy(CrashReporter( + context = testContext, services = listOf(service), telemetryServices = listOf(telemetryService), shouldPrompt = CrashReporter.Prompt.NEVER, @@ -527,3 +570,9 @@ class CrashReporterTest { assertTrue(Modifier.isVolatile(instanceField.modifiers)) } } + +private fun createUncaughtExceptionCrash(): Crash.UncaughtExceptionCrash { + return Crash.UncaughtExceptionCrash( + RuntimeException(), ArrayList() + ) +} \ No newline at end of file diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt index de235d9abad..d17a99d22f7 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/CrashHandlerServiceTest.kt @@ -40,6 +40,7 @@ class CrashHandlerServiceTest { fun setUp() { val scope = TestCoroutineScope(testDispatcher) reporter = spy(CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.NEVER, services = listOf(mock()), nonFatalCrashIntent = mock(), diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt index 5bce5a63248..695a9f12528 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/handler/ExceptionHandlerTest.kt @@ -38,6 +38,7 @@ class ExceptionHandlerTest { val service: CrashReporterService = mock() val crashReporter = spy(CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.NEVER, services = listOf(service), scope = scope @@ -59,15 +60,22 @@ class ExceptionHandlerTest { val defaultExceptionHandler: Thread.UncaughtExceptionHandler = mock() val crashReporter = CrashReporter( - shouldPrompt = CrashReporter.Prompt.NEVER, - services = listOf(object : CrashReporterService { - override fun report(crash: Crash.UncaughtExceptionCrash): String? = null + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, + services = listOf(object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null - override fun report(crash: Crash.NativeCodeCrash): String? = null + override fun report(crash: Crash.UncaughtExceptionCrash): String? = null - override fun report(throwable: Throwable, breadcrumbs: ArrayList): String? = null - }), - scope = scope + override fun report(crash: Crash.NativeCodeCrash): String? = null + + override fun report(throwable: Throwable, breadcrumbs: ArrayList): String? = null + }), + scope = scope ).install(testContext) val handler = ExceptionHandler( diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt index 6b2a005e90a..709de2d38ac 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/prompt/CrashReporterActivityTest.kt @@ -57,6 +57,7 @@ class CrashReporterActivityTest { @Test fun `Pressing close button sends report`() = runBlockingTest { CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS, services = listOf(service), scope = scope @@ -80,6 +81,7 @@ class CrashReporterActivityTest { @Test fun `Pressing restart button sends report`() = runBlockingTest { CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS, services = listOf(service), scope = scope @@ -103,6 +105,7 @@ class CrashReporterActivityTest { @Test fun `Custom message is set on CrashReporterActivity`() = runBlockingTest { CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS, promptConfiguration = CrashReporter.PromptConfiguration( message = "Hello World!", @@ -123,6 +126,7 @@ class CrashReporterActivityTest { @Test fun `Sending crash report saves checkbox state`() = runBlockingTest { CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.ALWAYS, services = listOf(service), scope = scope diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt index fbea0083693..6795008dcc8 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt @@ -56,8 +56,15 @@ class SendCrashReportServiceTest { fun `Send crash report will forward same crash to crash service`() { var caughtCrash: Crash.NativeCodeCrash? = null val crashReporter = spy(CrashReporter( - shouldPrompt = CrashReporter.Prompt.NEVER, + context = testContext, + shouldPrompt = CrashReporter.Prompt.NEVER, services = listOf(object : CrashReporterService { + override val id: String = "test" + + override val name: String = "TestReporter" + + override fun createCrashReportUrl(identifier: String): String? = null + override fun report(crash: Crash.UncaughtExceptionCrash): String? { fail("Didn't expect uncaught exception crash") return null @@ -72,8 +79,8 @@ class SendCrashReportServiceTest { fail("Didn't expect caught exception") return null } - }), - scope = scope + }), + scope = scope )).install(testContext) val originalCrash = Crash.NativeCodeCrash( "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt index b3bbb0c5670..4247a692f9f 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashTelemetryServiceTest.kt @@ -53,6 +53,7 @@ class SendCrashTelemetryServiceTest { fun `Send crash telemetry will forward same crash to crash telemetry service`() { var caughtCrash: Crash.NativeCodeCrash? = null val crashReporter = spy(CrashReporter( + context = testContext, shouldPrompt = CrashReporter.Prompt.NEVER, telemetryServices = listOf(object : CrashTelemetryService { override fun record(crash: Crash.UncaughtExceptionCrash) { diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SentryServiceTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SentryServiceTest.kt index 62a2d85b05f..3c01fa989f8 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SentryServiceTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SentryServiceTest.kt @@ -231,8 +231,9 @@ class SentryServiceTest { ) val reporter = spy(CrashReporter( - services = listOf(service), - shouldPrompt = CrashReporter.Prompt.NEVER + context = testContext, + services = listOf(service), + shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) `when`(client.context).thenReturn(clientContext) @@ -277,6 +278,7 @@ class SentryServiceTest { ) val reporter = spy(CrashReporter( + testContext, services = listOf(service), shouldPrompt = CrashReporter.Prompt.NEVER ).install(testContext)) diff --git a/samples/crash/src/main/AndroidManifest.xml b/samples/crash/src/main/AndroidManifest.xml index f64dc73c5c6..f2077b8c86f 100644 --- a/samples/crash/src/main/AndroidManifest.xml +++ b/samples/crash/src/main/AndroidManifest.xml @@ -25,6 +25,9 @@ + + + diff --git a/samples/crash/src/main/java/org/mozilla/samples/crash/CrashActivity.kt b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashActivity.kt index 76bf681ebc1..4a0294526f9 100644 --- a/samples/crash/src/main/java/org/mozilla/samples/crash/CrashActivity.kt +++ b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashActivity.kt @@ -10,9 +10,9 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle -import androidx.core.content.ContextCompat import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_crash.* import mozilla.components.lib.crash.Crash @@ -41,6 +41,7 @@ class CrashActivity : AppCompatActivity(), View.OnClickListener { fatalCrashButton.setOnClickListener(this) crashButton.setOnClickListener(this) fatalServiceCrashButton.setOnClickListener(this) + crashList.setOnClickListener(this) crashReporter.recordCrashBreadcrumb( Breadcrumb("CrashActivity onCreate", emptyMap(), "sample", Breadcrumb.Level.DEBUG, @@ -115,6 +116,12 @@ class CrashActivity : AppCompatActivity(), View.OnClickListener { startService(Intent(this, CrashService::class.java)) finish() } + + crashList -> { + startActivity(Intent(this, CrashListActivity::class.java)) + } + + else -> throw java.lang.RuntimeException("Unknown ID") } } } diff --git a/samples/crash/src/main/java/org/mozilla/samples/crash/CrashApplication.kt b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashApplication.kt index 3a114a82abb..a1f8d6ac993 100644 --- a/samples/crash/src/main/java/org/mozilla/samples/crash/CrashApplication.kt +++ b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashApplication.kt @@ -20,6 +20,7 @@ import mozilla.components.lib.crash.service.CrashReporterService import mozilla.components.lib.crash.service.GleanCrashReporterService import mozilla.components.service.glean.Glean import mozilla.components.support.base.crash.Breadcrumb +import java.util.UUID class CrashApplication : Application() { internal lateinit var crashReporter: CrashReporter @@ -31,7 +32,10 @@ class CrashApplication : Application() { Log.addSink(AndroidLogSink()) crashReporter = CrashReporter( - services = listOf(createDummyCrashService(this)), + context = this, + services = listOf( + createDummyCrashService(this) + ), telemetryServices = listOf(GleanCrashReporterService(applicationContext)), shouldPrompt = CrashReporter.Prompt.ALWAYS, promptConfiguration = CrashReporter.PromptConfiguration( @@ -57,25 +61,37 @@ private fun createDummyCrashService(context: Context): CrashReporterService { // For this sample we create a dummy service. In a real application this would be an instance of SentryCrashService // or SocorroCrashService. return object : CrashReporterService { + override val id: String = "dummy" + + override val name: String = "Dummy" + + override fun createCrashReportUrl(identifier: String): String? { + return "https://example.org/$identifier" + } + override fun report(crash: Crash.UncaughtExceptionCrash): String? { GlobalScope.launch(Dispatchers.Main) { Toast.makeText(context, "Uploading uncaught exception crash...", Toast.LENGTH_SHORT).show() } - return null + return createDummyId() } override fun report(crash: Crash.NativeCodeCrash): String? { GlobalScope.launch(Dispatchers.Main) { Toast.makeText(context, "Uploading native crash...", Toast.LENGTH_SHORT).show() } - return null + return createDummyId() } override fun report(throwable: Throwable, breadcrumbs: ArrayList): String? { GlobalScope.launch(Dispatchers.Main) { Toast.makeText(context, "Uploading caught exception...", Toast.LENGTH_SHORT).show() } - return null + return createDummyId() + } + + private fun createDummyId(): String { + return "dummy${UUID.randomUUID().toString().hashCode()}" } } } diff --git a/samples/crash/src/main/java/org/mozilla/samples/crash/CrashListActivity.kt b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashListActivity.kt new file mode 100644 index 00000000000..9a0d4d1d1d0 --- /dev/null +++ b/samples/crash/src/main/java/org/mozilla/samples/crash/CrashListActivity.kt @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.samples.crash + +import android.widget.Toast +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.ui.AbstractCrashListActivity + +/** + * Activity showing list of past crashes. + */ +class CrashListActivity : AbstractCrashListActivity() { + override val crashReporter: CrashReporter + get() = (application as CrashApplication).crashReporter + + override fun onCrashServiceSelected(url: String) { + Toast.makeText(this, "Go to: $url", Toast.LENGTH_SHORT).show() + } +} diff --git a/samples/crash/src/main/res/layout/activity_crash.xml b/samples/crash/src/main/res/layout/activity_crash.xml index b9f31f0231f..1bb1337d4e3 100644 --- a/samples/crash/src/main/res/layout/activity_crash.xml +++ b/samples/crash/src/main/res/layout/activity_crash.xml @@ -1,5 +1,6 @@ @@ -22,4 +23,10 @@ android:layout_height="wrap_content" android:text="@string/crash_fatal_service"/> +