diff --git a/components/lib/crash/build.gradle b/components/lib/crash/build.gradle index 53fd044c0bc..54f45f74be7 100644 --- a/components/lib/crash/build.gradle +++ b/components/lib/crash/build.gradle @@ -54,6 +54,7 @@ dependencies { testImplementation Dependencies.testing_robolectric testImplementation Dependencies.testing_mockito testImplementation Dependencies.testing_coroutines + testImplementation Dependencies.testing_mockwebserver } ext.gleanGenerateMarkdownDocs = true 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 1a9cc833d01..93be3ead46f 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 @@ -4,13 +4,32 @@ package mozilla.components.lib.crash.service +import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.VisibleForTesting import mozilla.components.lib.crash.Crash -import org.mozilla.geckoview.CrashReporter +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.geckoview.BuildConfig +import java.io.BufferedReader import java.io.File +import java.io.FileInputStream +import java.io.FileReader +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.net.HttpURLConnection +import java.net.URL +import java.nio.channels.Channels +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPOutputStream +import kotlin.random.Random -typealias GeckoCrashReporter = CrashReporter - +private val defaultServerUrl = "https://crash-reports.mozilla.com/submit?" + + "id=${BuildConfig.MOZ_APP_ID}&version=${BuildConfig.MOZILLA_VERSION}&${BuildConfig.MOZ_APP_BUILDID}" /** * A [CrashReporterService] implementation uploading crash reports to crash-stats.mozilla.com. * @@ -20,54 +39,215 @@ typealias GeckoCrashReporter = CrashReporter * [File a bug](https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro) if you would like to get your * app added to the whitelist. */ +@Suppress("TooManyFunctions", "LargeClass") class MozillaSocorroService( private val applicationContext: Context, - private val appName: String + private val appName: String, + private val serverUrl: String = defaultServerUrl ) : CrashReporterService { - override fun report(crash: Crash.UncaughtExceptionCrash) { - // Not implemented currently. + private val logger = Logger("mozac/MozillaSocorroCrashHelperService") + private val startTime = System.currentTimeMillis() + val partSet = mutableSetOf() - // In theory we could upload uncaught exception crashes to Socorro too. But Socorro is not the best tool for - // them and it is not used by the app teams. + override fun report(crash: Crash.UncaughtExceptionCrash) { + sendReport(crash.throwable, null, null) } override fun report(crash: Crash.NativeCodeCrash) { - // GeckoView comes with a crash reporter class that we can use here. For now we are assuming that we only want - // to upload native code crashes to Socorro if GeckoView is used. If this assumption is going to be wrong in - // the future then we can start inlining the functionality here. - sendViaGeckoViewCrashReporter(crash) + sendReport(null, crash.minidumpPath, crash.extrasPath) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sendReport(throwable: Throwable?, miniDumpFilePath: String?, extrasFilePath: String?) { + val serverUrl = URL(serverUrl) + val boundary = generateBoundary() + var conn: HttpURLConnection? = null + partSet.clear() + + try { + conn = serverUrl.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") + conn.setRequestProperty("Content-Encoding", "gzip") + + sendCrashData(conn.outputStream, boundary, throwable, miniDumpFilePath, extrasFilePath) + + BufferedReader(InputStreamReader(conn.inputStream)).use { + val response = StringBuffer() + var inputLine = it.readLine() + while (inputLine != null) { + response.append(inputLine) + inputLine = it.readLine() + } + + Logger.info("Crash reported to Socorro: $response") + } + } catch (e: IOException) { + Logger.error("failed to send report to Socorro", e) + } finally { + conn?.disconnect() + } + } + + private fun sendCrashData( + os: OutputStream, + boundary: String, + throwable: Throwable?, + miniDumpFilePath: String?, + extrasFilePath: String? + ) { + val gzipOs = GZIPOutputStream(os) + sendPart(gzipOs, boundary, "ProductName", appName) + + extrasFilePath?.let { + val extrasFile = File(it) + val extrasMap = readExtrasFromFile(extrasFile) + for (key in extrasMap.keys) { + sendPart(gzipOs, boundary, key, extrasMap[key]) + } + extrasFile.delete() + } + + throwable?.let { + sendPart(gzipOs, boundary, "JavaStackTrace", getExceptionStackTrace(it)) + } + + miniDumpFilePath?.let { + val minidumpFile = File(it) + sendFile(gzipOs, boundary, "upload_file_minidump", minidumpFile) + minidumpFile.delete() + } + + sendPackageInstallTime(gzipOs, boundary) + sendProcessName(gzipOs, boundary) + sendPart(gzipOs, boundary, "ProductID", BuildConfig.MOZ_APP_ID) + sendPart(gzipOs, boundary, "Version", BuildConfig.MOZ_APP_VERSION) + sendPart(gzipOs, boundary, "BuildID", BuildConfig.MOZ_APP_BUILDID) + sendPart(gzipOs, boundary, "Vendor", BuildConfig.MOZ_APP_VENDOR) + sendPart(gzipOs, boundary, "ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL) + sendPart(gzipOs, boundary, "StartupTime", TimeUnit.MILLISECONDS.toSeconds(startTime).toString()) + sendPart(gzipOs, boundary, "CrashTime", TimeUnit.MILLISECONDS.toSeconds( + System.currentTimeMillis()).toString()) + sendPart(gzipOs, boundary, "Android_PackageName", applicationContext.packageName) + sendPart(gzipOs, boundary, "Android_Manufacturer", Build.MANUFACTURER) + sendPart(gzipOs, boundary, "Android_Model", Build.MODEL) + sendPart(gzipOs, boundary, "Android_Board", Build.BOARD) + sendPart(gzipOs, boundary, "Android_Brand", Build.BRAND) + sendPart(gzipOs, boundary, "Android_Device", Build.DEVICE) + sendPart(gzipOs, boundary, "Android_Display", Build.DISPLAY) + sendPart(gzipOs, boundary, "Android_Fingerprint", Build.FINGERPRINT) + sendPart(gzipOs, boundary, "Android_Hardware", Build.HARDWARE) + sendPart(gzipOs, boundary, "Android_Version", "${Build.VERSION.SDK_INT} (${Build.VERSION.CODENAME})") + gzipOs.write(("\r\n--$boundary--\r\n").toByteArray()) + gzipOs.flush() + gzipOs.close() + } + + private fun sendProcessName(os: OutputStream, boundary: String) { + val pid = android.os.Process.myPid() + val manager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + manager.runningAppProcesses.filter { it.pid == pid }.forEach { + sendPart(os, boundary, "Android_ProcessName", it.processName) + return + } + } + + private fun sendPackageInstallTime(os: OutputStream, boundary: String) { + val packageManager = applicationContext.packageManager + try { + val packageInfo = packageManager.getPackageInfo(applicationContext.packageName, 0) + sendPart(os, boundary, "InstallTime", TimeUnit.MILLISECONDS.toSeconds( + packageInfo.lastUpdateTime).toString()) + } catch (e: PackageManager.NameNotFoundException) { + Logger.error("Error getting package info", e) + } + } + + private fun generateBoundary(): String { + val r0 = Random.nextInt(0, Int.MAX_VALUE) + val r1 = Random.nextInt(0, Int.MAX_VALUE) + return String.format("---------------------------%08X%08X", r0, r1) + } + + private fun sendPart(os: OutputStream, boundary: String, name: String, data: String?) { + if (data == null) { + return + } + + if (partSet.contains(name)) { + return + } else { + partSet.add(name) + } + + try { + os.write(("--$boundary\r\nContent-Disposition: form-data; " + + "name=$name\r\n\r\n$data\r\n").toByteArray()) + } catch (e: IOException) { + logger.error("Exception when sending $name", e) + } + } + + private fun sendFile(os: OutputStream, boundary: String, name: String, file: File) { + try { + os.write(("--${boundary}\r\n" + + "Content-Disposition: form-data; name=\"$name\"; " + + "filename=\"${file.getName()}\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n").toByteArray()) + val fileInputStream = FileInputStream(file).channel + fileInputStream.transferTo(0, fileInputStream.size(), Channels.newChannel(os)) + fileInputStream.close() + } catch (e: IOException) { + Logger.error("failed to send file", e) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun unescape(string: String): String { + return string.replace("\\\\\\\\", "\\").replace("\\\\n", "\n").replace("\\\\t", "\t") } - internal fun sendViaGeckoViewCrashReporter(crash: Crash.NativeCodeCrash) { - // GeckoView Nightly introduced a breaking API change to the crash reporter that has not been uplifted to beta. - // Since our CrashReporter does not follow the same abstractions as the engine, this results in - // a `NoSuchMethodError` being thrown. - // We should fix this in the future to make the crash reporter be part of the same engine extraction. - // See: https://github.com/mozilla-mobile/android-components/issues/4052 + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun readExtrasFromFile(file: File): HashMap { + var fileReader: FileReader? = null + var bufReader: BufferedReader? = null + var line: String? + val map = HashMap() + try { - GeckoCrashReporter.sendCrashReport( - applicationContext, - File(crash.minidumpPath), - File(crash.extrasPath), - appName - ) - } catch (e: NoSuchMethodError) { - val deprecatedMethod = GeckoCrashReporter::class.java.getDeclaredMethod( - "sendCrashReport", - Context::class.java, - File::class.java, - File::class.java, - Boolean::class.java, - String::class.java - ) - deprecatedMethod.invoke( - GeckoCrashReporter::class.java, - applicationContext, - File(crash.minidumpPath), - File(crash.extrasPath), - crash.minidumpSuccess, - appName - ) + fileReader = FileReader(file) + bufReader = BufferedReader(fileReader) + line = bufReader.readLine() + while (line != null) { + val equalsPos = line.indexOf('=') + if ((equalsPos) != -1) { + val key = line.substring(0, equalsPos) + val value = unescape(line.substring(equalsPos + 1)) + map[key] = value + } + line = bufReader.readLine() + } + } catch (e: IOException) { + Logger.error("failed to convert extras to map", e) + } finally { + try { + fileReader?.close() + bufReader?.close() + } catch (e: IOException) { + // do nothing + } } + + return map + } + + private fun getExceptionStackTrace(throwable: Throwable): String { + val stringWriter = StringWriter() + val printWriter = PrintWriter(stringWriter) + throwable.printStackTrace(printWriter) + printWriter.flush() + + return stringWriter.toString() } } diff --git a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt index 35c022934d2..bc64bb7a9e6 100644 --- a/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt +++ b/components/lib/crash/src/test/java/mozilla/components/lib/crash/service/MozillaSocorroServiceTest.kt @@ -5,15 +5,22 @@ package mozilla.components.lib.crash.service import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.io.Resources.getResource import mozilla.components.lib.crash.Crash import mozilla.components.support.test.any import mozilla.components.support.test.robolectric.testContext +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.doNothing import org.mockito.Mockito.spy import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoMoreInteractions +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStreamReader +import java.util.zip.GZIPInputStream @RunWith(AndroidJUnit4::class) class MozillaSocorroServiceTest { @@ -24,27 +31,146 @@ class MozillaSocorroServiceTest { testContext, "Test App" )) - doNothing().`when`(service).sendViaGeckoViewCrashReporter(any()) + doNothing().`when`(service).sendReport(any(), any(), any()) val crash = Crash.NativeCodeCrash("", true, "", false, arrayListOf()) service.report(crash) verify(service).report(crash) - verify(service).sendViaGeckoViewCrashReporter(crash) + verify(service).sendReport(null, crash.minidumpPath, crash.extrasPath) } @Test - fun `MozillaSocorroService does not send uncaught exception crashes`() { + fun `MozillaSocorroService send uncaught exception crashes`() { val service = spy(MozillaSocorroService( testContext, "Test App" )) - doNothing().`when`(service).sendViaGeckoViewCrashReporter(any()) + doNothing().`when`(service).sendReport(any(), any(), any()) val crash = Crash.UncaughtExceptionCrash(RuntimeException("Test"), arrayListOf()) service.report(crash) verify(service).report(crash) - verifyNoMoreInteractions(service) + verify(service).sendReport(crash.throwable, null, null) + } + + @Test + fun `MozillaSocorroService uncaught exception request is correct`() { + var mockWebServer = MockWebServer() + mockWebServer.enqueue(MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928")) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy(MozillaSocorroService( + testContext, + "Test App", + serverUrl.toString() + )) + + val crash = Crash.UncaughtExceptionCrash(RuntimeException("Test"), arrayListOf()) + service.report(crash) + + val fileInputStream = ByteArrayInputStream(mockWebServer.takeRequest().body.inputStream().readBytes()) + val inputStream = GZIPInputStream(fileInputStream) + val reader = InputStreamReader(inputStream) + val bufferedReader = BufferedReader(reader) + var request = bufferedReader.readText() + + assert(request.contains("name=JavaStackTrace\r\n\r\njava.lang.RuntimeException: Test")) + assert(request.contains("name=Android_ProcessName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=ProductID\r\n\r\n{aa3c5121-dab2-40e2-81ca-7ea25febc110}")) + assert(request.contains("name=Vendor\r\n\r\nMozilla")) + assert(request.contains("name=ReleaseChannel\r\n\r\nnightly")) + assert(request.contains("name=Android_PackageName\r\n\r\nmozilla.components.lib.crash.test")) + assert(request.contains("name=Android_Device\r\n\r\nrobolectric")) + + verify(service).report(crash) + verify(service).sendReport(crash.throwable, null, null) + } + + @Test + fun `MozillaSocorroService handles 200 response correctly`() { + var mockWebServer = MockWebServer() + mockWebServer.enqueue(MockResponse().setResponseCode(200) + .setBody("CrashID=bp-924121d3-4de3-4b32-ab12-026fc0190928")) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy(MozillaSocorroService( + testContext, + "Test App", + serverUrl.toString() + )) + + val crash = Crash.UncaughtExceptionCrash(RuntimeException("Test"), arrayListOf()) + service.report(crash) + + mockWebServer.shutdown() + verify(service).report(crash) + verify(service).sendReport(crash.throwable, null, null) + } + + @Test + fun `MozillaSocorroService handles 404 response correctly`() { + var mockWebServer = MockWebServer() + mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("error")) + mockWebServer.start() + val serverUrl = mockWebServer.url("/") + val service = spy(MozillaSocorroService( + testContext, + "Test App", + serverUrl.toString() + )) + + val crash = Crash.NativeCodeCrash("", true, "", false, arrayListOf()) + service.report(crash) + mockWebServer.shutdown() + + verify(service).report(crash) + verify(service).sendReport(null, crash.minidumpPath, crash.extrasPath) + } + + @Test + fun `MozillaSocorroService parses extrasFile correctly`() { + val service = spy(MozillaSocorroService( + testContext, + "Test App" + )) + val file = File(getResource("TestExtrasFile").file) + val extrasMap = service.readExtrasFromFile(file) + + assert(extrasMap.size == 9) + assert(extrasMap["InstallTime"] == "1569440259") + assert(extrasMap["ProductName"] == "Test Product") + assert(extrasMap["StartupCrash"] == "0") + assert(extrasMap["StartupTime"] == "1569642043") + assert(extrasMap["useragent_locale"] == "en-US") + assert(extrasMap["CrashTime"] == "1569642098") + assert(extrasMap["UptimeTS"] == "56.3663876") + assert(extrasMap["SecondsSinceLastCrash"] == "104") + assert(extrasMap["TextureUsage"] == "12345678") + } + + @Test + fun `MozillaSocorroService unescape strings correctly`() { + val service = spy(MozillaSocorroService( + testContext, + "Test App" + )) + val test1 = "\\\\\\\\" + val expected1 = "\\" + assert(service.unescape(test1) == expected1) + + val test2 = "\\\\n" + val expected2 = "\n" + assert(service.unescape(test2) == expected2) + + val test3 = "\\\\t" + val expected3 = "\t" + assert(service.unescape(test3) == expected3) + + val test4 = "\\\\\\\\\\\\t\\\\t\\\\n\\\\\\\\" + val expected4 = "\\\t\t\n\\" + assert(service.unescape(test4) == expected4) } } diff --git a/components/lib/crash/src/test/resources/TestExtrasFile b/components/lib/crash/src/test/resources/TestExtrasFile new file mode 100755 index 00000000000..bc70d74c23d --- /dev/null +++ b/components/lib/crash/src/test/resources/TestExtrasFile @@ -0,0 +1,9 @@ +InstallTime=1569440259 +ProductName=Test Product +StartupCrash=0 +StartupTime=1569642043 +useragent_locale=en-US +CrashTime=1569642098 +UptimeTS=56.3663876 +SecondsSinceLastCrash=104 +TextureUsage=12345678