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.', )} = ({