Browse Source

Add Crash activity (#8216)

* Add Crash activity

When the application crashes this sends them to a different activity with the cause message and an option to dump the crash logs

* Review changes
Andreas 2 years ago
parent
commit
4178f945c9

+ 6 - 0
app/src/main/AndroidManifest.xml

@@ -57,6 +57,12 @@
                 android:name="android.app.shortcuts"
                 android:resource="@xml/shortcuts" />
         </activity>
+
+        <activity
+            android:process=":error_handler"
+            android:name=".crash.CrashActivity"
+            android:exported="true" />
+
         <activity
             android:name=".ui.main.DeepLinkActivity"
             android:launchMode="singleTask"

+ 119 - 0
app/src/main/java/eu/kanade/presentation/crash/CrashScreen.kt

@@ -0,0 +1,119 @@
+package eu.kanade.presentation.crash
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BugReport
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.presentation.util.verticalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.util.CrashLogUtil
+import kotlinx.coroutines.launch
+
+@Composable
+fun CrashScreen(
+    exception: Throwable?,
+    onRestartClick: () -> Unit,
+) {
+    val scope = rememberCoroutineScope()
+    val context = LocalContext.current
+    Scaffold(
+        bottomBar = {
+            val strokeWidth = Dp.Hairline
+            val borderColor = MaterialTheme.colorScheme.outline
+            Column(
+                modifier = Modifier
+                    .drawBehind {
+                        drawLine(
+                            borderColor,
+                            Offset(0f, 0f),
+                            Offset(size.width, 0f),
+                            strokeWidth.value,
+                        )
+                    }
+                    .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+                verticalArrangement = Arrangement.spacedBy(verticalPadding),
+            ) {
+                Button(
+                    onClick = {
+                        scope.launch {
+                            CrashLogUtil(context).dumpLogs()
+                        }
+                    },
+                    modifier = Modifier.fillMaxWidth(),
+                ) {
+                    Text(text = stringResource(id = R.string.pref_dump_crash_logs))
+                }
+                OutlinedButton(
+                    onClick = onRestartClick,
+                    modifier = Modifier.fillMaxWidth(),
+                ) {
+                    Text(text = stringResource(R.string.crash_screen_restart_application))
+                }
+            }
+        },
+    ) { paddingValues ->
+        Column(
+            modifier = Modifier
+                .padding(paddingValues)
+                .padding(top = 56.dp)
+                .padding(horizontal = horizontalPadding)
+                .verticalScroll(rememberScrollState()),
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            Icon(
+                imageVector = Icons.Outlined.BugReport,
+                contentDescription = null,
+                modifier = Modifier
+                    .size(64.dp),
+            )
+            Text(
+                text = stringResource(R.string.crash_screen_title),
+                style = MaterialTheme.typography.titleLarge,
+            )
+            Text(
+                text = stringResource(R.string.crash_screen_description, stringResource(id = R.string.app_name)),
+                modifier = Modifier
+                    .padding(vertical = verticalPadding),
+            )
+            Box(
+                modifier = Modifier
+                    .padding(vertical = verticalPadding)
+                    .clip(MaterialTheme.shapes.small)
+                    .fillMaxWidth()
+                    .background(MaterialTheme.colorScheme.surfaceVariant),
+            ) {
+                Text(
+                    text = exception.toString(),
+                    modifier = Modifier
+                        .padding(all = verticalPadding),
+                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}

+ 2 - 1
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt

@@ -59,6 +59,7 @@ import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.powerManager
 import eu.kanade.tachiyomi.util.system.setDefaultSettings
 import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.launch
 import logcat.LogPriority
 import rikka.sui.Sui
 import uy.kohesive.injekt.Injekt
@@ -89,7 +90,7 @@ class SettingsAdvancedScreen : SearchableSettings {
                 title = stringResource(R.string.pref_dump_crash_logs),
                 subtitle = stringResource(R.string.pref_dump_crash_logs_summary),
                 onClick = {
-                    scope.launchNonCancellable {
+                    scope.launch {
                         CrashLogUtil(context).dumpLogs()
                     }
                 },

+ 4 - 0
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -30,6 +30,8 @@ import eu.kanade.domain.DomainModule
 import eu.kanade.domain.base.BasePreferences
 import eu.kanade.domain.ui.UiPreferences
 import eu.kanade.domain.ui.model.ThemeMode
+import eu.kanade.tachiyomi.crash.CrashActivity
+import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
 import eu.kanade.tachiyomi.data.coil.DomainMangaKeyer
 import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
 import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
@@ -74,6 +76,8 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
     override fun onCreate() {
         super<Application>.onCreate()
 
+        GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
+
         // TLS 1.3 support for Android < 10
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
             Security.insertProviderAt(Conscrypt.newProvider(), 1)

+ 25 - 0
app/src/main/java/eu/kanade/tachiyomi/crash/CrashActivity.kt

@@ -0,0 +1,25 @@
+package eu.kanade.tachiyomi.crash
+
+import android.content.Intent
+import android.os.Bundle
+import eu.kanade.presentation.crash.CrashScreen
+import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.util.view.setComposeContent
+
+class CrashActivity : BaseActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val exception = GlobalExceptionHandler.getThrowableFromIntent(intent)
+        setComposeContent {
+            CrashScreen(
+                exception = exception,
+                onRestartClick = {
+                    finishAffinity()
+                    startActivity(Intent(this@CrashActivity, MainActivity::class.java))
+                },
+            )
+        }
+    }
+}

+ 80 - 0
app/src/main/java/eu/kanade/tachiyomi/crash/GlobalExceptionHandler.kt

@@ -0,0 +1,80 @@
+package eu.kanade.tachiyomi.crash
+
+import android.content.Context
+import android.content.Intent
+import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.Json
+import logcat.LogPriority
+import kotlin.system.exitProcess
+
+class GlobalExceptionHandler private constructor(
+    private val applicationContext: Context,
+    private val defaultHandler: Thread.UncaughtExceptionHandler,
+    private val activityToBeLaunched: Class<*>,
+) : Thread.UncaughtExceptionHandler {
+
+    object ThrowableSerializer : KSerializer<Throwable> {
+        override val descriptor: SerialDescriptor =
+            PrimitiveSerialDescriptor("Throwable", PrimitiveKind.STRING)
+
+        override fun deserialize(decoder: Decoder): Throwable =
+            Throwable(message = decoder.decodeString())
+
+        override fun serialize(encoder: Encoder, value: Throwable) =
+            encoder.encodeString(value.stackTraceToString())
+    }
+
+    override fun uncaughtException(thread: Thread, exception: Throwable) {
+        try {
+            logcat(priority = LogPriority.ERROR, throwable = exception)
+            launchActivity(applicationContext, activityToBeLaunched, exception)
+            exitProcess(0)
+        } catch (_: Exception) {
+            defaultHandler.uncaughtException(thread, exception)
+        }
+    }
+
+    private fun launchActivity(
+        applicationContext: Context,
+        activity: Class<*>,
+        exception: Throwable,
+    ) {
+        val intent = Intent(applicationContext, activity).apply {
+            putExtra(INTENT_EXTRA, Json.encodeToString(ThrowableSerializer, exception))
+            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
+            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+        }
+        applicationContext.startActivity(intent)
+    }
+
+    companion object {
+        private const val INTENT_EXTRA = "Throwable"
+
+        fun initialize(
+            applicationContext: Context,
+            activityToBeLaunched: Class<*>,
+        ) {
+            val handler = GlobalExceptionHandler(
+                applicationContext,
+                Thread.getDefaultUncaughtExceptionHandler() as Thread.UncaughtExceptionHandler,
+                activityToBeLaunched,
+            )
+            Thread.setDefaultUncaughtExceptionHandler(handler)
+        }
+
+        fun getThrowableFromIntent(intent: Intent): Throwable? {
+            return try {
+                Json.decodeFromString(ThrowableSerializer, intent.getStringExtra(INTENT_EXTRA)!!)
+            } catch (e: Exception) {
+                logcat(LogPriority.ERROR, e) { "Wasn't able to retrive throwable from intent" }
+                null
+            }
+        }
+    }
+}

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt

@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.BuildConfig
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.notification.NotificationReceiver
 import eu.kanade.tachiyomi.data.notification.Notifications
+import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
 import eu.kanade.tachiyomi.util.lang.withUIContext
 import eu.kanade.tachiyomi.util.storage.getUriCompat
 import eu.kanade.tachiyomi.util.system.createFileInCacheDir
@@ -20,7 +21,7 @@ class CrashLogUtil(private val context: Context) {
         setSmallIcon(R.drawable.ic_tachi)
     }
 
-    suspend fun dumpLogs() {
+    suspend fun dumpLogs() = withNonCancellableContext {
         try {
             val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt")
             Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor()

+ 5 - 0
i18n/src/main/res/values/strings.xml

@@ -781,6 +781,11 @@
     <string name="empty_screen">Well, this is awkward</string>
     <string name="not_installed">Not installed</string>
 
+    <!-- Crash screen -->
+    <string name="crash_screen_title">An Unexpected Error Occurred</string>
+    <string name="crash_screen_description">%s ran into an unexpected error. We suggest you screenshot this message, dump the crash logs, and then share it in our support channel on Discord.</string>
+    <string name="crash_screen_restart_application">Restart the application</string>
+
     <!-- Downloads activity and service -->
     <string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string>
     <string name="download_insufficient_space">Couldn\'t download chapters due to low storage space</string>