diff --git a/components/lib/crash/src/main/AndroidManifest.xml b/components/lib/crash/src/main/AndroidManifest.xml index 251fcfa92f9..6792595fd3a 100644 --- a/components/lib/crash/src/main/AndroidManifest.xml +++ b/components/lib/crash/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt index 3ddfb84587a..06e86b1fe66 100644 --- a/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/notification/CrashNotification.kt @@ -15,12 +15,14 @@ import mozilla.components.lib.crash.Crash import mozilla.components.lib.crash.CrashReporter import mozilla.components.lib.crash.R import mozilla.components.lib.crash.prompt.CrashPrompt +import mozilla.components.lib.crash.service.SendCrashReportService import mozilla.components.support.base.ids.notify -private const val NOTIFICATION_CHANNEL_ID = "Crashes" -private const val NOTIFICATION_TAG = "mozac.lib.crash.CRASH" private const val NOTIFICATION_SDK_LEVEL = 29 // On Android Q+ we show a notification instead of a prompt +internal const val NOTIFICATION_CHANNEL_ID = "Crashes" +internal const val NOTIFICATION_TAG = "mozac.lib.crash.CRASH" + internal class CrashNotification( private val context: Context, private val crash: Crash, @@ -28,10 +30,20 @@ internal class CrashNotification( ) { fun show() { val pendingIntent = PendingIntent.getActivity( - context, 0, CrashPrompt.createIntent(context, crash), 0 + context, 0, CrashPrompt.createIntent(context, crash), 0 ) - val channel = ensureChannelExists() + val reportPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService( + context, 0, SendCrashReportService.createReportIntent(context, crash), 0 + ) + } else { + PendingIntent.getService( + context, 0, SendCrashReportService.createReportIntent(context, crash), 0 + ) + } + + val channel = ensureChannelExists(context) val notification = NotificationCompat.Builder(context, channel) .setContentTitle(context.getString(R.string.mozac_lib_crash_dialog_title, configuration.appName)) @@ -39,6 +51,8 @@ internal class CrashNotification( .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_ERROR) .setContentIntent(pendingIntent) + .addAction(R.drawable.mozac_lib_crash_notification, context.getString( + R.string.mozac_lib_crash_notification_action_report), reportPendingIntent) .setAutoCancel(true) .build() @@ -46,24 +60,6 @@ internal class CrashNotification( .notify(context, NOTIFICATION_TAG, notification) } - private fun ensureChannelExists(): String { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager: NotificationManager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - context.getString(R.string.mozac_lib_crash_channel), - NotificationManager.IMPORTANCE_DEFAULT - ) - - notificationManager.createNotificationChannel(channel) - } - - return NOTIFICATION_CHANNEL_ID - } - companion object { /** * Whether to show a notification instead of a prompt (activity). Android introduced restrictions on background @@ -82,5 +78,23 @@ internal class CrashNotification( else -> crash is Crash.NativeCodeCrash && crash.isFatal } } + + fun ensureChannelExists(context: Context): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager: NotificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + context.getString(R.string.mozac_lib_crash_channel), + NotificationManager.IMPORTANCE_DEFAULT + ) + + notificationManager.createNotificationChannel(channel) + } + + return NOTIFICATION_CHANNEL_ID + } } } diff --git a/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt new file mode 100644 index 00000000000..3fbf5bacf14 --- /dev/null +++ b/components/lib/crash/src/main/java/mozilla/components/lib/crash/service/SendCrashReportService.kt @@ -0,0 +1,91 @@ +/* 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.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.lib.crash.R +import mozilla.components.lib.crash.notification.CrashNotification +import mozilla.components.lib.crash.notification.NOTIFICATION_TAG +import mozilla.components.support.base.ids.NotificationIds +import mozilla.components.support.base.ids.cancel +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +class SendCrashReportService : Service() { + private val crashReporter: CrashReporter by lazy { CrashReporter.requireInstance } + private val logger by lazy { CrashReporter + .requireInstance + .logger + } + + private var reporterCoroutineContext: CoroutineContext = EmptyCoroutineContext + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = CrashNotification.ensureChannelExists(this) + val notification = NotificationCompat.Builder(this, channel) + .setContentTitle(getString(R.string.mozac_lib_send_crash_report_in_progress, + crashReporter.promptConfiguration.organizationName)) + .setSmallIcon(R.drawable.mozac_lib_crash_notification) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .setProgress(0, 0, true) + .build() + + val notificationId = NotificationIds.getIdForTag(this, NOTIFICATION_TAG) + startForeground(notificationId, notification) + } + + intent.extras?.let { extras -> + val crash = Crash.NativeCodeCrash.fromBundle(extras) + NotificationManagerCompat.from(this).cancel(this, NOTIFICATION_TAG) + + sendCrashReport(crash) { + stopSelf() + } + } ?: logger.error("Received intent with null extras") + + return START_NOT_STICKY + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendCrashReport(crash: Crash, then: () -> Unit) { + GlobalScope.launch(reporterCoroutineContext) { + crashReporter.submitReport(crash) + + withContext(Dispatchers.Main) { + then() + } + } + } + + override fun onBind(intent: Intent): IBinder? { + // We don't provide binding, so return null + return null + } + + companion object { + fun createReportIntent(context: Context, crash: Crash): Intent { + val intent = Intent(context, SendCrashReportService::class.java) + crash.fillIn(intent) + + return intent + } + } +} diff --git a/components/lib/crash/src/main/res/values/strings.xml b/components/lib/crash/src/main/res/values/strings.xml index f1823ffb561..b98fa27148f 100644 --- a/components/lib/crash/src/main/res/values/strings.xml +++ b/components/lib/crash/src/main/res/values/strings.xml @@ -21,4 +21,6 @@ Report + + Sending crash report to %1$s 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 new file mode 100644 index 00000000000..55f6dac8fb0 --- /dev/null +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/SendCrashReportServiceTest.kt @@ -0,0 +1,78 @@ +/* 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.service + +import android.content.ComponentName +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.lib.crash.Crash +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.robolectric.testContext +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.Robolectric + +@RunWith(AndroidJUnit4::class) +class SendCrashReportServiceTest { + private var service: SendCrashReportService? = null + + @Before + fun setUp() { + service = spy(Robolectric.setupService(SendCrashReportService::class.java)) + service?.startService(Intent()) + } + + @After + fun tearDown() { + service?.stopService(Intent()) + CrashReporter.reset() + } + + @Test + fun `CrashRHandlerService will forward same crash to crash reporter`() { + spy(CrashReporter( + shouldPrompt = CrashReporter.Prompt.ALWAYS, + services = listOf(object : CrashReporterService { + override fun report(crash: Crash.UncaughtExceptionCrash) { + } + + override fun report(crash: Crash.NativeCodeCrash) { + } + })) + ).install(testContext) + val originalCrash = Crash.NativeCodeCrash( + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp", + true, + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra", + false + ) + + val intent = Intent("org.mozilla.gecko.ACTION_CRASHED") + intent.component = ComponentName( + "org.mozilla.samples.browser", + "mozilla.components.lib.crash.handler.CrashHandlerService" + ) + intent.putExtra( + "minidumpPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.dmp" + ) + intent.putExtra("fatal", false) + intent.putExtra( + "extrasPath", + "/data/data/org.mozilla.samples.browser/files/mozilla/Crash Reports/pending/3ba5f665-8422-dc8e-a88e-fc65c081d304.extra" + ) + intent.putExtra("minidumpSuccess", true) + originalCrash.fillIn(intent) + + service?.onStartCommand(intent, 0, 0) + verify(service)?.sendCrashReport(eq(originalCrash), any()) + } +}