diff --git a/App.tsx b/App.tsx
index 25ab60a5..62261abe 100644
--- a/App.tsx
+++ b/App.tsx
@@ -14,6 +14,7 @@ import {
import AppErrorBoundary, {
ErrorFallback,
} from '@components/AppErrorBoundary/AppErrorBoundary';
+import CrashRecoveryGate from '@components/AppErrorBoundary/CrashRecoveryGate';
import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
@@ -64,7 +65,7 @@ const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
return {children};
};
-const App = () => {
+const Application = () => {
useRozeniteSqlitePlugin({ adapters: sqliteAdapters });
const { success: databaseReady, error: databaseError } = useInitDatabase();
const { ready: servicesReady, error: servicesError } =
@@ -80,9 +81,7 @@ const App = () => {
if (initializationError) {
return (
-
- null} />
-
+ null} />
);
}
@@ -91,30 +90,33 @@ const App = () => {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
+const App = () => (
+
+
+
+
+
+
+
+);
+
export default App;
const styles = StyleSheet.create({
diff --git a/index.js b/index.js
index 8f42a73f..c32c6f60 100644
--- a/index.js
+++ b/index.js
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
import { AppRegistry, I18nManager } from 'react-native';
import { i18n } from './src/i18n/translations';
import { runHeadlessBackgroundTask } from './src/services/backgroundTasks';
+import { installJsCrashHandler } from './src/services/crashLogs/installJsCrashHandler';
+
+installJsCrashHandler();
AppRegistry.registerHeadlessTask(
'LNReaderBackgroundTask',
diff --git a/modules/native-crash-logs/android/build.gradle b/modules/native-crash-logs/android/build.gradle
new file mode 100644
index 00000000..a977a0e0
--- /dev/null
+++ b/modules/native-crash-logs/android/build.gradle
@@ -0,0 +1,24 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.nativecrashlogs'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.nativecrashlogs"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.17.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/modules/native-crash-logs/android/src/main/AndroidManifest.xml b/modules/native-crash-logs/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..34de0007
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashExceptionHandler.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashExceptionHandler.kt
new file mode 100644
index 00000000..e7da54e5
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashExceptionHandler.kt
@@ -0,0 +1,57 @@
+package expo.modules.nativecrashlogs
+
+import android.content.Context
+import android.os.Process
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.system.exitProcess
+
+internal class CrashExceptionHandler private constructor(
+ private val applicationContext: Context,
+ private val defaultHandler: Thread.UncaughtExceptionHandler?,
+) : Thread.UncaughtExceptionHandler {
+ override fun uncaughtException(thread: Thread, exception: Throwable) {
+ try {
+ val recordedJsCrash = CrashLogStore.readPendingCrash(applicationContext)
+ CrashLogStore.writePendingCrash(
+ applicationContext,
+ buildString {
+ appendLine("Native crash captured: ${timestamp()}")
+ appendLine("Thread: ${thread.name}")
+ appendLine()
+ append(exception.stackTraceToString())
+ if (recordedJsCrash != null) {
+ appendLine()
+ appendLine()
+ appendLine("Previously recorded JavaScript crash:")
+ append(recordedJsCrash)
+ }
+ },
+ )
+ } catch (_: Throwable) {
+ // The platform handler must still terminate the process normally.
+ } finally {
+ val handler = defaultHandler
+ if (handler != null) {
+ handler.uncaughtException(thread, exception)
+ } else {
+ Process.killProcess(Process.myPid())
+ exitProcess(10)
+ }
+ }
+ }
+
+ companion object {
+ fun install(context: Context) {
+ val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
+ if (currentHandler is CrashExceptionHandler) return
+ Thread.setDefaultUncaughtExceptionHandler(
+ CrashExceptionHandler(context.applicationContext, currentHandler),
+ )
+ }
+
+ private fun timestamp(): String =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT).format(Date())
+ }
+}
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashHandlerInitializationProvider.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashHandlerInitializationProvider.kt
new file mode 100644
index 00000000..a071a4fe
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashHandlerInitializationProvider.kt
@@ -0,0 +1,34 @@
+package expo.modules.nativecrashlogs
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+
+class CrashHandlerInitializationProvider : ContentProvider() {
+ override fun onCreate(): Boolean {
+ context?.let(CrashExceptionHandler::install)
+ return true
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = 0
+}
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogFileProvider.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogFileProvider.kt
new file mode 100644
index 00000000..be6739e6
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogFileProvider.kt
@@ -0,0 +1,5 @@
+package expo.modules.nativecrashlogs
+
+import androidx.core.content.FileProvider
+
+class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogRedactor.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogRedactor.kt
new file mode 100644
index 00000000..5b09ee32
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogRedactor.kt
@@ -0,0 +1,15 @@
+package expo.modules.nativecrashlogs
+
+internal object CrashLogRedactor {
+ private val headerPattern = Regex(
+ pattern = "(?i)(authorization|cookie|set-cookie)(\\s*[:=]\\s*).*$",
+ options = setOf(RegexOption.MULTILINE),
+ )
+ private val urlQueryPattern = Regex(
+ pattern = "(?i)(https?://[^\\s?#]+)\\?[^\\s#\"')\\]]+",
+ )
+
+ fun redact(contents: String): String = contents
+ .replace(headerPattern, "$1$2[REDACTED]")
+ .replace(urlQueryPattern, "$1?[REDACTED]")
+}
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogStore.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogStore.kt
new file mode 100644
index 00000000..9f4ff52c
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/CrashLogStore.kt
@@ -0,0 +1,48 @@
+package expo.modules.nativecrashlogs
+
+import android.content.Context
+import java.io.File
+
+internal object CrashLogStore {
+ private const val CRASH_DIRECTORY = "crash-reports"
+ private const val PENDING_CRASH_FILE = "last_crash.txt"
+ private const val SHARE_DIRECTORY = "crash-logs"
+ private const val SHARE_FILE = "lnreader_crash_logs.txt"
+
+ fun createShareFile(context: Context): File {
+ val directory = File(context.cacheDir, SHARE_DIRECTORY)
+ check(directory.exists() || directory.mkdirs()) {
+ "Failed to create crash log cache directory"
+ }
+ return File(directory, SHARE_FILE)
+ }
+
+ fun readPendingCrash(context: Context): String? {
+ val file = pendingCrashFile(context)
+ if (!file.isFile) return null
+ return file.readText().takeIf { it.isNotBlank() }
+ }
+
+ fun writePendingCrash(context: Context, contents: String) {
+ val file = pendingCrashFile(context)
+ val directory = file.parentFile
+ check(directory != null && (directory.exists() || directory.mkdirs())) {
+ "Failed to create crash report directory"
+ }
+
+ val temporaryFile = File(directory, "$PENDING_CRASH_FILE.tmp")
+ temporaryFile.writeText(contents)
+ if (!temporaryFile.renameTo(file)) {
+ file.writeText(contents)
+ temporaryFile.delete()
+ }
+ }
+
+ fun clearPendingCrash(context: Context) {
+ val file = pendingCrashFile(context)
+ check(!file.exists() || file.delete()) { "Failed to clear pending crash report" }
+ }
+
+ private fun pendingCrashFile(context: Context): File =
+ File(File(context.filesDir, CRASH_DIRECTORY), PENDING_CRASH_FILE)
+}
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/LogcatCollector.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/LogcatCollector.kt
new file mode 100644
index 00000000..772c3832
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/LogcatCollector.kt
@@ -0,0 +1,73 @@
+package expo.modules.nativecrashlogs
+
+import android.content.Context
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+internal object LogcatCollector {
+ private const val MAX_LOG_BYTES = 512 * 1024
+ private const val MAX_LOG_LINES = 2_000
+ private const val LOGCAT_TIMEOUT_SECONDS = 2L
+
+ fun collect(context: Context, requestedLevel: String): String {
+ val level = if (requestedLevel == "V") "V" else "E"
+ val output = File.createTempFile("lnreader-logcat-", ".tmp", context.cacheDir)
+ return try {
+ val process = ProcessBuilder(
+ "logcat",
+ "-d",
+ "-t",
+ MAX_LOG_LINES.toString(),
+ "-v",
+ "year",
+ "-v",
+ "zone",
+ "*:$level",
+ )
+ .redirectErrorStream(true)
+ .redirectOutput(output)
+ .start()
+
+ if (!process.waitFor(LOGCAT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ process.destroyForcibly()
+ }
+ readBounded(output)
+ } catch (error: Exception) {
+ "Logcat unavailable: ${error.message ?: error.javaClass.simpleName}"
+ } finally {
+ output.delete()
+ }
+ }
+
+ private fun readBounded(file: File): String {
+ if (!file.isFile || file.length() == 0L) return "No accessible logcat entries."
+
+ val wasTruncated = file.length() > MAX_LOG_BYTES
+ val bytes = file.inputStream().use { input ->
+ var bytesToSkip = if (wasTruncated) file.length() - MAX_LOG_BYTES else 0L
+ while (bytesToSkip > 0) {
+ val skipped = input.skip(bytesToSkip)
+ if (skipped <= 0) break
+ bytesToSkip -= skipped
+ }
+ val buffer = ByteArray(MAX_LOG_BYTES)
+ var totalRead = 0
+ while (totalRead < buffer.size) {
+ val count = input.read(buffer, totalRead, buffer.size - totalRead)
+ if (count == -1) break
+ totalRead += count
+ }
+ buffer.copyOf(totalRead)
+ }
+ val sanitized = bytes.toString(Charsets.UTF_8)
+ .lineSequence()
+ .joinToString("\n", transform = CrashLogRedactor::redact)
+
+ return if (wasTruncated) {
+ "[Older logcat entries truncated]\n$sanitized"
+ } else {
+ sanitized
+ }
+ }
+
+}
diff --git a/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/NativeCrashLogsModule.kt b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/NativeCrashLogsModule.kt
new file mode 100644
index 00000000..2d49d2c8
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/java/expo/modules/nativecrashlogs/NativeCrashLogsModule.kt
@@ -0,0 +1,163 @@
+package expo.modules.nativecrashlogs
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.webkit.WebView
+import androidx.core.content.FileProvider
+import expo.modules.kotlin.Promise
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class NativeCrashLogsModule : Module() {
+ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun definition() = ModuleDefinition {
+ Name("NativeCrashLogs")
+
+ OnCreate {
+ appContext.reactContext?.let(CrashExceptionHandler::install)
+ }
+
+ OnDestroy {
+ coroutineScope.cancel()
+ }
+
+ AsyncFunction("shareCrashLogs") { report: String, logLevel: String, promise: Promise ->
+ coroutineScope.launch {
+ try {
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ val file = CrashLogStore.createShareFile(context)
+ file.writeText(
+ CrashLogRedactor.redact(
+ report.take(MAX_REPORT_CHARACTERS).trimEnd(),
+ ),
+ )
+ file.appendText("\n\n[App-accessible logcat]\n")
+ file.appendText(LogcatCollector.collect(context, logLevel))
+
+ withContext(Dispatchers.Main) {
+ shareFile(context, file)
+ }
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("ECRASHLOG", "Failed to share crash logs", error)
+ }
+ }
+ }
+
+ Function("recordJsCrash") { stackTrace: String ->
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ CrashLogStore.writePendingCrash(context, stackTrace.take(MAX_REPORT_CHARACTERS))
+ }
+
+ AsyncFunction("getPendingCrash") { promise: Promise ->
+ coroutineScope.launch {
+ try {
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ promise.resolve(CrashLogStore.readPendingCrash(context))
+ } catch (error: Exception) {
+ promise.reject("ECRASHREAD", "Failed to read pending crash", error)
+ }
+ }
+ }
+
+ AsyncFunction("clearPendingCrash") { promise: Promise ->
+ coroutineScope.launch {
+ try {
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ CrashLogStore.clearPendingCrash(context)
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("ECRASHCLEAR", "Failed to clear pending crash", error)
+ }
+ }
+ }
+
+ AsyncFunction("getDebugInfo") { promise: Promise ->
+ coroutineScope.launch {
+ try {
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ val webViewVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ WebView.getCurrentWebViewPackage()?.versionName
+ } else {
+ null
+ }
+ promise.resolve(mapOf("webViewVersion" to webViewVersion))
+ } catch (error: Exception) {
+ promise.reject("EDEBUGINFO", "Failed to read native debug info", error)
+ }
+ }
+ }
+
+ Function("restartApp") {
+ val context = requireNotNull(appContext.reactContext) {
+ "React context is unavailable"
+ }
+ val launchIntent = requireNotNull(
+ context.packageManager.getLaunchIntentForPackage(context.packageName),
+ ) { "Application launch intent is unavailable" }
+ try {
+ CrashLogStore.clearPendingCrash(context)
+ } catch (_: Exception) {
+ // Restart remains useful even if stale crash cleanup fails.
+ }
+ launchIntent.addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP,
+ )
+ val activity = appContext.currentActivity
+ if (activity == null) {
+ context.startActivity(launchIntent)
+ } else {
+ activity.runOnUiThread {
+ activity.finishAffinity()
+ activity.startActivity(launchIntent)
+ }
+ }
+ }
+ }
+
+ private fun shareFile(context: Context, file: java.io.File) {
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.crash-logs",
+ file,
+ )
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ clipData = ClipData.newRawUri("LNReader crash logs", uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val chooser = Intent.createChooser(shareIntent, "Share crash logs")
+ if (appContext.currentActivity == null) {
+ chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooser)
+ } else {
+ appContext.currentActivity?.startActivity(chooser)
+ }
+ }
+
+ companion object {
+ private const val MAX_REPORT_CHARACTERS = 256 * 1024
+ }
+}
diff --git a/modules/native-crash-logs/android/src/main/res/xml/crash_log_paths.xml b/modules/native-crash-logs/android/src/main/res/xml/crash_log_paths.xml
new file mode 100644
index 00000000..4404035e
--- /dev/null
+++ b/modules/native-crash-logs/android/src/main/res/xml/crash_log_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/modules/native-crash-logs/android/src/test/java/expo/modules/nativecrashlogs/CrashLogRedactorTest.kt b/modules/native-crash-logs/android/src/test/java/expo/modules/nativecrashlogs/CrashLogRedactorTest.kt
new file mode 100644
index 00000000..138813a3
--- /dev/null
+++ b/modules/native-crash-logs/android/src/test/java/expo/modules/nativecrashlogs/CrashLogRedactorTest.kt
@@ -0,0 +1,26 @@
+package expo.modules.nativecrashlogs
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CrashLogRedactorTest {
+ @Test
+ fun redactsSensitiveHeaders() {
+ val report = "Authorization: Bearer secret\nCookie=session-secret\nmessage"
+
+ assertEquals(
+ "Authorization: [REDACTED]\nCookie=[REDACTED]\nmessage",
+ CrashLogRedactor.redact(report),
+ )
+ }
+
+ @Test
+ fun redactsUrlQueries() {
+ val report = "Request failed: https://example.com/chapter?id=12&token=secret"
+
+ assertEquals(
+ "Request failed: https://example.com/chapter?[REDACTED]",
+ CrashLogRedactor.redact(report),
+ )
+ }
+}
diff --git a/modules/native-crash-logs/expo-module.config.json b/modules/native-crash-logs/expo-module.config.json
new file mode 100644
index 00000000..df81f76d
--- /dev/null
+++ b/modules/native-crash-logs/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.nativecrashlogs.NativeCrashLogsModule"]
+ }
+}
diff --git a/modules/native-crash-logs/index.ts b/modules/native-crash-logs/index.ts
new file mode 100644
index 00000000..1410fce5
--- /dev/null
+++ b/modules/native-crash-logs/index.ts
@@ -0,0 +1,3 @@
+import NativeCrashLogs from './src/NativeCrashLogsModule';
+
+export default NativeCrashLogs;
diff --git a/modules/native-crash-logs/package.json b/modules/native-crash-logs/package.json
new file mode 100644
index 00000000..114637f8
--- /dev/null
+++ b/modules/native-crash-logs/package.json
@@ -0,0 +1,3 @@
+{
+ "name": "native-crash-logs"
+}
diff --git a/modules/native-crash-logs/src/NativeCrashLogsModule.ts b/modules/native-crash-logs/src/NativeCrashLogsModule.ts
new file mode 100644
index 00000000..f032bf56
--- /dev/null
+++ b/modules/native-crash-logs/src/NativeCrashLogsModule.ts
@@ -0,0 +1,18 @@
+import { requireOptionalNativeModule } from 'expo-modules-core';
+
+export type NativeCrashDebugInfo = {
+ webViewVersion: string | null;
+};
+
+export type NativeCrashLogsModule = {
+ shareCrashLogs(report: string, logLevel: 'E' | 'V'): Promise;
+ recordJsCrash(stackTrace: string): void;
+ getPendingCrash(): Promise;
+ clearPendingCrash(): Promise;
+ getDebugInfo(): Promise;
+ restartApp(): void;
+};
+
+export default requireOptionalNativeModule(
+ 'NativeCrashLogs',
+);
diff --git a/package.json b/package.json
index e2b28a15..44d59ba1 100644
--- a/package.json
+++ b/package.json
@@ -148,6 +148,7 @@
"reactNativeDirectoryCheck": {
"exclude": [
"native-file",
+ "native-crash-logs",
"nitro-tts",
"native-volume-button-listener",
"native-zip-archive",
diff --git a/src/components/AppErrorBoundary/AppErrorBoundary.tsx b/src/components/AppErrorBoundary/AppErrorBoundary.tsx
index 9afb5f36..64bce4ce 100644
--- a/src/components/AppErrorBoundary/AppErrorBoundary.tsx
+++ b/src/components/AppErrorBoundary/AppErrorBoundary.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import { StyleSheet, View, Text, StatusBar } from 'react-native';
import ErrorBoundary from 'react-native-error-boundary';
import * as Clipboard from 'expo-clipboard';
@@ -8,6 +8,7 @@ import { showToast } from '@utils/showToast';
import { Button, List } from '@components';
import { useTheme } from '@hooks/persisted';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { restartApplication, shareCrashLogs } from '@services/crashLogs';
interface ErrorFallbackProps {
error: Error;
@@ -19,6 +20,7 @@ export const ErrorFallback: React.FC = ({
resetError,
}) => {
const theme = useTheme();
+ const [isSharing, setIsSharing] = useState(false);
const fallbackGetString = (
key: Parameters[0],
@@ -34,22 +36,42 @@ export const ErrorFallback: React.FC = ({
const chainMessages = useMemo(() => getErrorChainMessages(error), [error]);
- const handleCopyStackTrace = async () => {
+ const copyStackTrace = async () => {
try {
const message = chainMessages.join('\n\nCaused by: ');
await Clipboard.setStringAsync(`${message}\n\n${error.stack}`);
- showToast(
- fallbackGetString(
- 'common.copiedToClipboard',
- 'Copied to clipboard: Stack trace',
- { name: 'Stack trace' },
- ),
- );
+ return true;
} catch {
- // clipboard failure is non-critical
+ return false;
}
};
+ const handleShareCrashLogs = async () => {
+ setIsSharing(true);
+ try {
+ await shareCrashLogs(error);
+ } catch {
+ const copiedStackTrace = await copyStackTrace();
+ showToast(
+ copiedStackTrace
+ ? fallbackGetString(
+ 'errorBoundary.shareCrashLogsFailed',
+ 'Could not share crash logs. The stack trace was copied instead.',
+ )
+ : fallbackGetString(
+ 'errorBoundary.shareCrashLogsFailedWithoutCopy',
+ 'Could not share crash logs or copy the stack trace.',
+ ),
+ );
+ } finally {
+ setIsSharing(false);
+ }
+ };
+
+ const handleRestart = () => {
+ restartApplication(resetError).catch(resetError);
+ };
+
return (
= ({
{fallbackGetString(
'errorBoundary.description',
- 'The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.',
+ 'The application ran into an unexpected error. Please share the crash logs in our Discord support channel.',
)}
= ({