diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index be23f69..427ed23 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v1 - - name: set up JDK 1.8 + - name: set up JDK 11 uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: 11 - name: Build with Gradle run: ./gradlew build test \ No newline at end of file diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml index 9cd7ce0..37d611f 100644 --- a/.github/workflows/style-check.yml +++ b/.github/workflows/style-check.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v1 - - name: set up JDK 1.8 + - name: set up JDK 11 uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: 11 - name: Ktlint check run: ./gradlew ktlintCheck \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 61a9130..fb7f4a8 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..875a112 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,23 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 7bb9736..b8e0b1b 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,34 @@ + + + - + diff --git a/.jitpack.yml b/.jitpack.yml new file mode 100644 index 0000000..f78f664 --- /dev/null +++ b/.jitpack.yml @@ -0,0 +1 @@ +jdk: openjdk11 \ No newline at end of file diff --git a/README.md b/README.md index b5ebd52..97b9cf8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,15 @@ dependencies { The library ships with Proguard rules to ensure that it works correctly even after minification. +### Multi-Process Service + +`WhatTheStack` runs a bound service in a separate process to show you the error screen on a crash. + +We need to run this code in a separate process because you can't reliably launch new Activities +in the host application's process after an uncaught exception is thrown. + + + ## Contributions We are happy to accept any external contributions in the form of PRs, issues, or blog posts. diff --git a/app/build.gradle b/app/build.gradle index e469a44..297b44a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,7 +41,11 @@ android { } buildFeatures { - viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "1.0.5" } } @@ -53,9 +57,12 @@ dependencies { implementation libs.kotlinStdLib implementation libs.appCompat implementation libs.coreKtx - implementation libs.fragmentKtx - implementation libs.constraintLayout - implementation libs.materialComponents + + implementation libs.composeActivity + implementation libs.composeMaterial + implementation libs.composeTooling + implementation libs.accompanistSysUi + implementation libs.accompanistInsets testImplementation libs.junit diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6840f78..b7bfb9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,11 +5,13 @@ - + diff --git a/app/src/main/java/com/haroldadmin/crashyapp/MainActivity.kt b/app/src/main/java/com/haroldadmin/crashyapp/MainActivity.kt index 4c890cc..a4cfa9d 100644 --- a/app/src/main/java/com/haroldadmin/crashyapp/MainActivity.kt +++ b/app/src/main/java/com/haroldadmin/crashyapp/MainActivity.kt @@ -1,20 +1,19 @@ package com.haroldadmin.crashyapp import android.os.Bundle +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import com.haroldadmin.crashyapp.databinding.ActivityMainBinding +import com.haroldadmin.crashyapp.ui.pages.HomePage +import com.haroldadmin.crashyapp.ui.theme.CrashyAppTheme class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.crashButton.setOnClickListener { - throw BecauseICanException() + setContent { + CrashyAppTheme { + HomePage() + } } } } - -private class BecauseICanException : Exception("This exception is thrown purely because it can be thrown") diff --git a/app/src/main/java/com/haroldadmin/crashyapp/ui/pages/HomePage.kt b/app/src/main/java/com/haroldadmin/crashyapp/ui/pages/HomePage.kt new file mode 100644 index 0000000..66eb41c --- /dev/null +++ b/app/src/main/java/com/haroldadmin/crashyapp/ui/pages/HomePage.kt @@ -0,0 +1,41 @@ +package com.haroldadmin.crashyapp.ui.pages + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun HomePage() { + Scaffold( + topBar = { + TopAppBar { + Text(text = "Crashy App", style = MaterialTheme.typography.h6) + } + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .fillMaxHeight() + ) { + Text( + text = "Press the button to see the error screen from WhatTheStack!", + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { throw BecauseICanException() }) { + Text(text = "Crash!") + } + } + } +} + +private class BecauseICanException : + Exception("This exception is thrown purely because it can be thrown") diff --git a/app/src/main/java/com/haroldadmin/crashyapp/ui/theme/CrashyAppTheme.kt b/app/src/main/java/com/haroldadmin/crashyapp/ui/theme/CrashyAppTheme.kt new file mode 100644 index 0000000..c14ff85 --- /dev/null +++ b/app/src/main/java/com/haroldadmin/crashyapp/ui/theme/CrashyAppTheme.kt @@ -0,0 +1,20 @@ +package com.haroldadmin.crashyapp.ui.theme + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val ColorPalette = lightColors( + primary = Color(0xffd32f2f), + primaryVariant = Color(0xff9a0007), + secondary = Color(0xff616161), + secondaryVariant = Color(0x33373737), +) + +@Composable +fun CrashyAppTheme( + content: @Composable () -> Unit +) { + MaterialTheme(colors = ColorPalette, content = content) +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 0131968..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index f12742b..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - #d32f2f - #ff6659 - #9a0007 - #616161 - #8e8e8e - #373737 - #ffffff - #ffffff - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index d1882f5..0000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Crashy App - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 05ba9fc..f3f0b8f 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,13 +1,4 @@ - - - diff --git a/build.gradle b/build.gradle index 613bb94..658afa7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,9 @@ buildscript { ext.buildConfig = [ "applicationId": "com.haroldadmin.crashyapp", - "compileSdk" : 29, + "compileSdk" : 31, "minSdk" : 21, - "targetSdk" : 29, + "targetSdk" : 31, "versionCode" : 1, "versionName" : "0.0.1" ] @@ -33,7 +33,7 @@ allprojects { subprojects { apply plugin: "org.jlleitschuh.gradle.ktlint" ktlint { - version = "0.36.0" + version = "0.43.0" ignoreFailures = false disabledRules = ["no-wildcard-imports"] } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a3d007..35a7f95 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -kotlin = "1.5.20" -agp = "4.2.0" +kotlin = "1.5.31" +agp = "7.0.3" ktlint = "10.1.0" appCompat = "1.3.0" coreTest = "2.0.0" @@ -17,9 +17,17 @@ espressoCore = "3.2.0" mockk = "1.9.3" robolectric = "4.3.1" startup = "1.0.0" +composeActivity = "1.3.1" +compose = "1.0.5" +accompanist = "0.21.3-beta" [libraries] +composeActivity = { module = "androidx.activity:activity-compose", version.ref = "composeActivity" } +composeMaterial = { module = "androidx.compose.material:material", version.ref = "compose" } +composeTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +accompanistSysUi = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } +accompanistInsets = { module = "com.google.accompanist:accompanist-insets", version.ref = "accompanist" } agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ktlintGradlePlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } diff --git a/what-the-stack/build.gradle b/what-the-stack/build.gradle index 564ebb8..a9122bc 100644 --- a/what-the-stack/build.gradle +++ b/what-the-stack/build.gradle @@ -36,7 +36,11 @@ android { } buildFeatures { - viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.compose.get() } } @@ -46,11 +50,13 @@ dependencies { implementation libs.kotlinStdLib implementation libs.appCompat implementation libs.coreKtx - implementation libs.fragmentKtx - implementation libs.constraintLayout - implementation libs.materialComponents implementation libs.startup - implementation libs.insetter + + implementation libs.composeActivity + implementation libs.composeMaterial + implementation libs.composeTooling + implementation libs.accompanistSysUi + implementation libs.accompanistInsets testImplementation libs.junit testImplementation libs.mockk diff --git a/what-the-stack/src/main/AndroidManifest.xml b/what-the-stack/src/main/AndroidManifest.xml index 5f3d7e6..3d37122 100644 --- a/what-the-stack/src/main/AndroidManifest.xml +++ b/what-the-stack/src/main/AndroidManifest.xml @@ -13,10 +13,12 @@ diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/Annotations.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/Annotations.kt new file mode 100644 index 0000000..a84ab18 --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/Annotations.kt @@ -0,0 +1,17 @@ +package com.haroldadmin.whatthestack + +/** + * Indicates that the annotated class/function runs in the host application's + * process, not in the bound service's process + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +internal annotation class HostAppProcess + +/** + * Indicates that the annotated class/function runs in the bound service's + * process, not in the host app's process + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +internal annotation class ServiceProcess diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ExceptionProcessor.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ExceptionProcessor.kt index d02d8bb..2d564a5 100644 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ExceptionProcessor.kt +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ExceptionProcessor.kt @@ -1,7 +1,7 @@ package com.haroldadmin.whatthestack import android.os.Parcelable -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize /** * Represents the data of the exception to be displayed to the user. diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStack.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStack.kt deleted file mode 100644 index d54f471..0000000 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStack.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.haroldadmin.whatthestack - -import android.content.Context -import android.content.Intent -import android.os.Messenger - -/** - * **DO NOT USE** - * A class to allow initialization of WhatTheStack service. - * - * WhatTheStack initializes automatically using a content provider. You do not need to initialize - * it explicitly using this class. - * - * @param applicationContext The context used to start the service to catch uncaught exceptions - */ -@Deprecated( - "WhatTheStack initializes automatically at application startup. Do not explicitly initialize it", - level = DeprecationLevel.ERROR -) -class WhatTheStack(private val applicationContext: Context) { - - @Suppress("unused") - fun init() { - InitializationManager.init(applicationContext) - } -} - -internal object InitializationManager { - - private var isInitialized: Boolean = false - - private val connection = WhatTheStackConnection( - onConnected = { binder -> - val messenger = Messenger(binder) - val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() - val exceptionHandler = WhatTheStackExceptionHandler(messenger, defaultHandler) - Thread.setDefaultUncaughtExceptionHandler(exceptionHandler) - } - ) - - fun init(applicationContext: Context) { - if (isInitialized) { return } - isInitialized = true - val intent = Intent(applicationContext, WhatTheStackService::class.java) - applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) - } -} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackActivity.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackActivity.kt index 54c46c6..108afee 100644 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackActivity.kt +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackActivity.kt @@ -1,18 +1,14 @@ package com.haroldadmin.whatthestack -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.text.method.ScrollingMovementMethod +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat -import com.google.android.material.snackbar.Snackbar -import com.haroldadmin.whatthestack.databinding.ActivityWhatTheStackBinding -import dev.chrisbanes.insetter.Insetter -import dev.chrisbanes.insetter.windowInsetTypesOf +import com.google.accompanist.insets.ProvideWindowInsets +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.haroldadmin.whatthestack.ui.pages.ExceptionPage +import com.haroldadmin.whatthestack.ui.theme.SystemBarsColor +import com.haroldadmin.whatthestack.ui.theme.WhatTheStackTheme /** * An Activity which displays various pieces of information regarding the exception which @@ -20,90 +16,27 @@ import dev.chrisbanes.insetter.windowInsetTypesOf */ class WhatTheStackActivity : AppCompatActivity() { - private lateinit var binding: ActivityWhatTheStackBinding - - private val clipboardManager: ClipboardManager by lazy { - getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityWhatTheStackBinding.inflate(layoutInflater) - setContentView(binding.root) - WindowCompat.setDecorFitsSystemWindows(window, false) - Insetter.builder() - .padding(windowInsetTypesOf(statusBars = true, navigationBars = true)) - .applyToView(binding.root) - - val type = intent.getStringExtra(KEY_EXCEPTION_TYPE) - val cause = intent.getStringExtra(KEY_EXCEPTION_CAUSE) - val message = intent.getStringExtra(KEY_EXCEPTION_MESSAGE) - val stackTrace = intent.getStringExtra(KEY_EXCEPTION_STACKTRACE) - - binding.stacktrace.apply { - text = stackTrace - setHorizontallyScrolling(true) - movementMethod = ScrollingMovementMethod() - } - - binding.exceptionName.apply { - text = getString(R.string.exception_name, type) - } - - binding.exceptionCause.apply { - text = getString(R.string.exception_cause, cause) - } - - binding.exceptionMessage.apply { - text = getString(R.string.exception_message, message) - } - - binding.copyStacktrace.apply { - setOnClickListener { - val clipping = ClipData.newPlainText("stacktrace", stackTrace) - clipboardManager.setPrimaryClip(clipping) - snackbar { R.string.copied_message } - } - } - - binding.shareStacktrace.apply { - setOnClickListener { - val sendIntent: Intent = Intent().apply { - this.action = Intent.ACTION_SEND - this.putExtra(Intent.EXTRA_TEXT, stackTrace) - this.type = "text/plain" + val type = intent.getStringExtra(KEY_EXCEPTION_TYPE) ?: "" + val message = intent.getStringExtra(KEY_EXCEPTION_MESSAGE) ?: "" + val stackTrace = intent.getStringExtra(KEY_EXCEPTION_STACKTRACE) ?: "" + + setContent { + val sysUiController = rememberSystemUiController() + sysUiController.setSystemBarsColor(SystemBarsColor) + + WhatTheStackTheme { + ProvideWindowInsets { + ExceptionPage( + type = type, + message = message, + stackTrace = stackTrace + ) } - - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(shareIntent) - } - } - - binding.launchApplication.apply { - setOnClickListener { - context.packageManager.getLaunchIntentForPackage(applicationContext.packageName) - ?.let { - it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(it) - } } } - - binding.searchStackoverflow.apply { - setOnClickListener { - val searchQuery = "$cause: $message" - val searchIntent: Intent = Intent().apply { - this.action = Intent.ACTION_VIEW - this.data = Uri.parse(generateStackoverflowSearchUrl(searchQuery)) - } - startActivity(searchIntent) - } - } - } - - private inline fun snackbar(messageProvider: () -> Int) { - Snackbar.make(binding.nestedScrollRoot, messageProvider(), Snackbar.LENGTH_SHORT).show() } } diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackConnection.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackConnection.kt deleted file mode 100644 index 16df4d5..0000000 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackConnection.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.haroldadmin.whatthestack - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.IBinder - -internal class WhatTheStackConnection( - private val onDisconnected: () -> Unit = { Unit }, - private val onConnected: (IBinder) -> Unit -) : ServiceConnection { - - override fun onServiceDisconnected(name: ComponentName?) { - onDisconnected() - } - - override fun onServiceConnected(name: ComponentName?, service: IBinder) { - onConnected(service) - } -} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackExceptionHandler.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackExceptionHandler.kt index d581270..c3b7353 100644 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackExceptionHandler.kt +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackExceptionHandler.kt @@ -2,32 +2,36 @@ package com.haroldadmin.whatthestack import android.os.Message import android.os.Messenger -import android.os.Process import androidx.core.os.bundleOf /** * A [Thread.UncaughtExceptionHandler] which is meant to be used as a default exception handler on - * the application. Any uncaught exceptions which are handled by this handler are processed and - * send to [WhatTheStackService] to show the error screen. + * the application. + * + * It runs in the host app's process to: + * 1. Process any exception it catches and forward the result in a [Message] to [WhatTheStackService] + * 2. Call the default exception handler it replaced, if any + * 3. Kill the app process if there was no previous default exception handler */ - +@HostAppProcess internal class WhatTheStackExceptionHandler( - private val service: Messenger, - private val defaultHandler: Thread.UncaughtExceptionHandler? + private val serviceMessenger: Messenger, + private val defaultHandler: Thread.UncaughtExceptionHandler?, ) : Thread.UncaughtExceptionHandler { override fun uncaughtException(t: Thread, e: Throwable) { - + e.printStackTrace() val exceptionData = e.process() + serviceMessenger.send( + Message().apply { + data = bundleOf( + KEY_EXCEPTION_TYPE to exceptionData.type, + KEY_EXCEPTION_CAUSE to exceptionData.cause, + KEY_EXCEPTION_MESSAGE to exceptionData.message, + KEY_EXCEPTION_STACKTRACE to exceptionData.stacktrace + ) + } + ) - service.send(Message().apply { - data = bundleOf( - KEY_EXCEPTION_TYPE to exceptionData.type, - KEY_EXCEPTION_CAUSE to exceptionData.cause, - KEY_EXCEPTION_MESSAGE to exceptionData.message, - KEY_EXCEPTION_STACKTRACE to exceptionData.stacktrace - ) - }) - - defaultHandler?.uncaughtException(t, e) ?: Process.killProcess(Process.myPid()) + defaultHandler?.uncaughtException(t, e) } } diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackInitializer.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackInitializer.kt index bf4475e..3a51c69 100644 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackInitializer.kt +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackInitializer.kt @@ -1,30 +1,57 @@ package com.haroldadmin.whatthestack +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.Messenger import androidx.startup.Initializer import java.lang.Class /** * WhatTheStackInitializer is an [androidx.startup.Initializer] for WhatTheStack - * - * This particular initializer does not need to return anything, but it is required to return - * a sensible value here so we return an instance of a dummy class [WhatTheStackInitializedToken] - * instead. */ -class WhatTheStackInitializer : Initializer { +@HostAppProcess +class WhatTheStackInitializer : Initializer { - override fun create(context: Context): WhatTheStackInitializedToken { - InitializationManager.init(context) - return WhatTheStackInitializedToken() - } + /** + * Runs in the host app's process to: + * + * 1. Start [WhatTheStackService] as a bound service to allow communication between the + * app's process and the service's process + * 2. Replace the app's default [Thread.UncaughtExceptionHandler] with [WhatTheStackExceptionHandler] + * when the service is connected. + * + * This method does not need to return anything, but it is required to return + * a sensible value here so we return a dummy object [InitializedToken] instead. + */ + override fun create(context: Context): InitializedToken { + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder) { + val messenger = Messenger(service) + val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + val customExceptionHandler = WhatTheStackExceptionHandler( + messenger, + defaultExceptionHandler + ) + Thread.setDefaultUncaughtExceptionHandler(customExceptionHandler) + } + + override fun onServiceDisconnected(name: ComponentName?) = Unit + } - override fun dependencies(): List>> { - return emptyList() + val intent = Intent(context, WhatTheStackService::class.java) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + + return InitializedToken } -} -/** - * A dummy class that does nothing but represent a type that can be returned by - * [WhatTheStackInitializer] - */ -class WhatTheStackInitializedToken + override fun dependencies(): List>> = emptyList() + + /** + * A dummy object that does nothing but represent a type that can be returned by + * [WhatTheStackInitializer] + */ + object InitializedToken +} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackService.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackService.kt index 19a8cd5..fcae7e4 100644 --- a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackService.kt +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/WhatTheStackService.kt @@ -3,49 +3,64 @@ package com.haroldadmin.whatthestack import android.app.Service import android.content.Context import android.content.Intent -import android.os.Handler -import android.os.IBinder -import android.os.Message -import android.os.Messenger +import android.os.* /** - * A Bound Service which runs in a separate process than the application. + * A Bound Service which runs in a separate process than the host application. * - * Messages are sent to this service when an uncaught exception is thrown in the consuming - * application. This exception is caught using a default exception handler set by this library, - * which processes the exception and sends it as a message to this service. + * This service must be started with [Context.bindService]. A bound service lives only as long as + * the calling context, so `bindService` must be called on an **APPLICATION CONTEXT**. * - * This service then starts an activity with the processed exception data as an intent extra. + * [WhatTheStackInitializer] starts this service, and it dies when the host app terminates. + * Therefore we don't need to explicitly handle [Service.onCreate], [Service.onDestroy] or call + * [Service.stopSelf]. + * + * [WhatTheStackExceptionHandler] sends messages to this service whenever an uncaught exception + * is thrown in the host application. This service then starts an activity with the processed + * exception data as an intent extra. */ - +@ServiceProcess class WhatTheStackService : Service() { - + /** + * [Handler] that runs on the main thread to handle incoming processed uncaught + * exceptions from [WhatTheStackExceptionHandler] + * + * We need to lazily initialize it because [getApplicationContext] returns null right + * after the service is created. + */ private val handler by lazy { WhatTheStackHandler(applicationContext) } + /** + * Runs when [WhatTheStackInitializer] calls [Context.bindService] to create a connection + * to this service. + * + * It creates a [Messenger] that can be used to communicate with its [handler], + * and returns its [IBinder]. + */ override fun onBind(intent: Intent?): IBinder? { - return Messenger(handler).binder + val messenger = Messenger(handler) + return messenger.binder } +} + +/** + * A [Handler] that runs on the main thread of the service process to process + * incoming uncaught exception messages. + */ +@ServiceProcess +private class WhatTheStackHandler( + private val applicationContext: Context +) : Handler(Looper.getMainLooper()) { - internal class WhatTheStackHandler( - private val applicationContext: Context - ) : Handler() { - override fun handleMessage(msg: Message) { - val type = msg.data.getString(KEY_EXCEPTION_TYPE) - val cause = msg.data.getString(KEY_EXCEPTION_CAUSE) - val message = msg.data.getString(KEY_EXCEPTION_MESSAGE) - val stacktrace = msg.data.getString(KEY_EXCEPTION_STACKTRACE) - Intent() - .apply { - setClass(applicationContext, WhatTheStackActivity::class.java) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra(KEY_EXCEPTION_TYPE, type) - putExtra(KEY_EXCEPTION_CAUSE, cause) - putExtra(KEY_EXCEPTION_MESSAGE, message) - putExtra(KEY_EXCEPTION_STACKTRACE, stacktrace) - } - .also { intent -> - applicationContext.startActivity(intent) - } - } + override fun handleMessage(msg: Message) { + Intent() + .apply { + setClass(applicationContext, WhatTheStackActivity::class.java) + putExtras(msg.data) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + .also { intent -> + applicationContext.startActivity(intent) + } } } diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OutlinedIconButton.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OutlinedIconButton.kt new file mode 100644 index 0000000..6d47bcc --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OutlinedIconButton.kt @@ -0,0 +1,38 @@ +package com.haroldadmin.whatthestack.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign + +@Composable +internal fun OutlinedIconButton( + text: String, + @DrawableRes iconId: Int, + onClick: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MaterialTheme.colors.background, + contentColor = MaterialTheme.colors.onBackground, + disabledContentColor = MaterialTheme.colors.onBackground.copy(alpha = 0.5f) + ), + ) { + Icon( + painter = painterResource(id = iconId), + contentDescription = contentDescription + ) + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } +} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OverlineLabel.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OverlineLabel.kt new file mode 100644 index 0000000..bbd9524 --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/components/OverlineLabel.kt @@ -0,0 +1,21 @@ +package com.haroldadmin.whatthestack.ui.components + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight + +/** + * A text label with the "overline" typography style + */ +@Composable +internal fun OverlineLabel(label: String, modifier: Modifier = Modifier) { + Text( + text = label, + style = MaterialTheme.typography.overline, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.onSurface, + modifier = modifier + ) +} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/pages/ExceptionPage.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/pages/ExceptionPage.kt new file mode 100644 index 0000000..931c5b9 --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/pages/ExceptionPage.kt @@ -0,0 +1,225 @@ +package com.haroldadmin.whatthestack.ui.pages + +import android.content.Intent +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.insets.navigationBarsHeight +import com.google.accompanist.insets.statusBarsHeight +import com.haroldadmin.whatthestack.R +import com.haroldadmin.whatthestack.generateStackoverflowSearchUrl +import com.haroldadmin.whatthestack.ui.components.OutlinedIconButton +import com.haroldadmin.whatthestack.ui.components.OverlineLabel +import com.haroldadmin.whatthestack.ui.preview.SampleData +import com.haroldadmin.whatthestack.ui.theme.WhatTheStackTheme +import kotlinx.coroutines.launch + +@Composable +fun ExceptionPage( + type: String, + message: String, + stackTrace: String +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val scaffoldState = rememberScaffoldState() + val coroutineScope = rememberCoroutineScope() + + val snackbarMessage = stringResource(id = R.string.copied_message) + + Scaffold(scaffoldState = scaffoldState) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.statusBarsHeight(additional = 8.dp)) + PageHeader() + ExceptionDetails( + type = type, + message = message, + modifier = Modifier.padding(vertical = 8.dp) + ) + ExceptionOptions( + onCopy = { + coroutineScope.launch { + clipboard.setText(AnnotatedString(stackTrace)) + scaffoldState.snackbarHostState.showSnackbar(snackbarMessage) + } + }, + onShare = { + val sendIntent: Intent = Intent().apply { + this.action = Intent.ACTION_SEND + this.putExtra(Intent.EXTRA_TEXT, stackTrace) + this.type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Stacktrace") + context.startActivity(shareIntent) + }, + onSearch = { + val searchQuery = "$type: $message" + val url = generateStackoverflowSearchUrl(searchQuery) + val searchIntent = Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse(url) + } + context.startActivity(searchIntent) + }, + onRestart = { + val applicationContext = context.applicationContext + val packageManager = applicationContext.packageManager + val packageName = applicationContext.packageName + + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.startActivity(launchIntent) + } + } + ) + Stacktrace( + stackTrace = stackTrace, + modifier = Modifier.padding(top = 8.dp) + ) + Spacer(modifier = Modifier.navigationBarsHeight(additional = 8.dp)) + } + } +} + +@Composable +fun PageHeader() { + Text( + stringResource(id = R.string.header_text), + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colors.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.explanation_text), + color = MaterialTheme.colors.onBackground + ) +} + +@Composable +fun ExceptionDetails(type: String, message: String, modifier: Modifier) { + Column(modifier = modifier) { + OverlineLabel(label = stringResource(id = R.string.exception_name)) + Text( + text = type, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colors.onBackground + ) + Spacer(modifier = Modifier.height(8.dp)) + OverlineLabel(label = stringResource(id = R.string.exception_message)) + Text( + text = message, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colors.onBackground + ) + } +} + +@Composable +fun ExceptionOptions( + onCopy: () -> Unit, + onShare: () -> Unit, + onRestart: () -> Unit, + onSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedIconButton( + text = stringResource(id = R.string.copy_stacktrace), + iconId = R.drawable.ic_outline_content_copy_24, + onClick = onCopy, + contentDescription = "Copy", + modifier = Modifier.padding(vertical = 4.dp), + ) + OutlinedIconButton( + text = stringResource(id = R.string.share_stacktrace), + iconId = R.drawable.ic_outline_share_24, + onClick = onShare, + contentDescription = "Share", + modifier = Modifier.padding(vertical = 4.dp) + ) + OutlinedIconButton( + text = stringResource(id = R.string.search_stackoverflow), + iconId = R.drawable.ic_round_search_24, + onClick = onSearch, + contentDescription = "Search Stackoverflow", + modifier = Modifier.padding(vertical = 4.dp) + ) + OutlinedIconButton( + text = stringResource(id = R.string.restart_application), + iconId = R.drawable.ic_baseline_refresh_24, + onClick = onRestart, + contentDescription = "Restart" + ) + } +} + +@Composable +fun Stacktrace(stackTrace: String, modifier: Modifier) { + Column(modifier) { + OverlineLabel(label = stringResource(id = R.string.stacktrace)) + Surface(modifier = Modifier.padding(top = 4.dp)) { + SelectionContainer { + Text( + text = stackTrace, + style = MaterialTheme.typography.body2.copy(fontSize = 12.sp), + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colors.primary, + modifier = Modifier + .padding(4.dp) + .horizontalScroll(rememberScrollState()) + ) + } + } + } +} + +@Preview +@Composable +fun ExceptionPagePreview() { + WhatTheStackTheme { + ExceptionPage( + type = SampleData.ExceptionType, + message = SampleData.ExceptionMessage, + stackTrace = SampleData.Stacktrace + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun ExceptionPagePreviewNightMode() { + WhatTheStackTheme { + ExceptionPage( + type = SampleData.ExceptionType, + message = SampleData.ExceptionMessage, + stackTrace = SampleData.Stacktrace + ) + } +} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/preview/SampleData.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/preview/SampleData.kt new file mode 100644 index 0000000..a16438f --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/preview/SampleData.kt @@ -0,0 +1,32 @@ +package com.haroldadmin.whatthestack.ui.preview + +object SampleData { + const val ExceptionType = "Runtime Exception" + + const val ExceptionMessage = "This exception was thrown purely because it can be thrown" + + const val Stacktrace = + """java.lang.RuntimeException: java.lang.reflect.InvocationTargetException + at com.android.internal.os.RuntimeInitMethodAndArgsCaller.run(RuntimeInit.java:558) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003) +Caused by: java.lang.reflect.InvocationTargetException + at java.lang.reflect.Method.invoke(Native Method) + at com.android.internal.os.RuntimeInitMethodAndArgsCaller.run(RuntimeInit.java:548) + ... 1 more +Caused by: com.haroldadmin.crashyapp.BecauseICanException: This exception is thrown purely because it can be thrown + at com.haroldadmin.crashyapp.MainActivity.onCreatelambda-0(MainActivity.kt:15) + at com.haroldadmin.crashyapp.MainActivity.r8lambdapFZVHP1EeT4E2LW7TLA5yGBRTTk(Unknown Source:0) + at com.haroldadmin.crashyapp.MainActivityxternalSyntheticLambda0.onClick(Unknown Source:0) + at android.view.View.performClick(View.java:7441) + at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1119) + at android.view.View.performClickInternal(View.java:7418) + at android.view.View.access$3700(View.java:835) + at android.view.ViewPerformClick.run(View.java:28676) + at android.os.Handler.handleCallback(Handler.java:938) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7839) + ... 3 more +""" +} diff --git a/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/theme/WhatTheStackTheme.kt b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/theme/WhatTheStackTheme.kt new file mode 100644 index 0000000..120e11f --- /dev/null +++ b/what-the-stack/src/main/java/com/haroldadmin/whatthestack/ui/theme/WhatTheStackTheme.kt @@ -0,0 +1,38 @@ +package com.haroldadmin.whatthestack.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorPalette = darkColors( + primary = Color(0xffd32f2f), + primaryVariant = Color(0xffff6659), + secondary = Color(0xff616161), + secondaryVariant = Color(0xff373737), +) + +private val LightColorPalette = lightColors( + primary = Color(0xffd32f2f), + primaryVariant = Color(0xff9a0007), + secondary = Color(0xff616161), + secondaryVariant = Color(0x33373737), +) + +internal val SystemBarsColor = Color(0x33373737) + +@Composable +fun WhatTheStackTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme(colors = colors, content = content) +} diff --git a/what-the-stack/src/main/res/drawable/ic_baseline_refresh_24.xml b/what-the-stack/src/main/res/drawable/ic_baseline_refresh_24.xml index f2be45b..5ab492c 100644 --- a/what-the-stack/src/main/res/drawable/ic_baseline_refresh_24.xml +++ b/what-the-stack/src/main/res/drawable/ic_baseline_refresh_24.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/what-the-stack/src/main/res/drawable/ic_outline_content_copy_24.xml b/what-the-stack/src/main/res/drawable/ic_outline_content_copy_24.xml index 79c5a76..eb384e5 100644 --- a/what-the-stack/src/main/res/drawable/ic_outline_content_copy_24.xml +++ b/what-the-stack/src/main/res/drawable/ic_outline_content_copy_24.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorOnPrimary"> + android:viewportHeight="24"> diff --git a/what-the-stack/src/main/res/drawable/ic_outline_share_24.xml b/what-the-stack/src/main/res/drawable/ic_outline_share_24.xml index 3a6a059..394e3ef 100644 --- a/what-the-stack/src/main/res/drawable/ic_outline_share_24.xml +++ b/what-the-stack/src/main/res/drawable/ic_outline_share_24.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/what-the-stack/src/main/res/drawable/ic_round_search_24.xml b/what-the-stack/src/main/res/drawable/ic_round_search_24.xml index c1818d5..b86e6fc 100644 --- a/what-the-stack/src/main/res/drawable/ic_round_search_24.xml +++ b/what-the-stack/src/main/res/drawable/ic_round_search_24.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/what-the-stack/src/main/res/layout/activity_what_the_stack.xml b/what-the-stack/src/main/res/layout/activity_what_the_stack.xml deleted file mode 100644 index 7e85fa7..0000000 --- a/what-the-stack/src/main/res/layout/activity_what_the_stack.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/what-the-stack/src/main/res/values/colors.xml b/what-the-stack/src/main/res/values/colors.xml deleted file mode 100644 index 9f135e1..0000000 --- a/what-the-stack/src/main/res/values/colors.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - #d32f2f - #ff6659 - #9a0007 - #616161 - #8e8e8e - #373737 - #33373737 - #ffffff - #ffffff - \ No newline at end of file diff --git a/what-the-stack/src/main/res/values/strings.xml b/what-the-stack/src/main/res/values/strings.xml index 2fecf52..54394d8 100644 --- a/what-the-stack/src/main/res/values/strings.xml +++ b/what-the-stack/src/main/res/values/strings.xml @@ -26,14 +26,13 @@ at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)  - Exception: %1$s - Cause: %1$s + Exception Stacktrace - Message: %1$s - Copy - Share - Restart + Message + Copy Stacktrace + Share Stacktrace + Restart Application Relaunch App Stacktrace copied! - Stackoverflow + Search Stackoverflow \ No newline at end of file diff --git a/what-the-stack/src/main/res/values/styles.xml b/what-the-stack/src/main/res/values/styles.xml index 98b8f55..0870988 100644 --- a/what-the-stack/src/main/res/values/styles.xml +++ b/what-the-stack/src/main/res/values/styles.xml @@ -1,14 +1,4 @@ - +