feat: Add Crash Log Collection And Sharing (Closes #11)

This commit is contained in:
Rajarshee Chatterjee
2026-09-15 14:16:51 +05:30
parent a12cba030d
commit a5e2b4d4f0
34 changed files with 1106 additions and 45 deletions
+27 -25
View File
@@ -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 <PaperProvider theme={paperTheme}>{children}</PaperProvider>;
};
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 (
<ThemeProvider>
<ErrorFallback error={initializationError} resetError={() => null} />
</ThemeProvider>
<ErrorFallback error={initializationError} resetError={() => null} />
);
}
@@ -91,30 +90,33 @@ const App = () => {
}
return (
<Suspense fallback={null}>
<ThemeProvider>
<ThemedRootView>
<KeyboardProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar
translucent={true}
backgroundColor="transparent"
/>
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</KeyboardProvider>
</ThemedRootView>
</ThemeProvider>
</Suspense>
<ThemedRootView>
<KeyboardProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</KeyboardProvider>
</ThemedRootView>
);
};
const App = () => (
<Suspense fallback={null}>
<ThemeProvider>
<CrashRecoveryGate>
<Application />
</CrashRecoveryGate>
</ThemeProvider>
</Suspense>
);
export default App;
const styles = StyleSheet.create({
+3
View File
@@ -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',
@@ -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'
}
@@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="expo.modules.nativecrashlogs.CrashHandlerInitializationProvider"
android:authorities="${applicationId}.crash-handler-initializer"
android:exported="false"
android:initOrder="110" />
<provider
android:name="expo.modules.nativecrashlogs.CrashLogFileProvider"
android:authorities="${applicationId}.crash-logs"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/crash_log_paths" />
</provider>
</application>
</manifest>
@@ -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())
}
}
@@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}
@@ -0,0 +1,5 @@
package expo.modules.nativecrashlogs
import androidx.core.content.FileProvider
class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
@@ -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]")
}
@@ -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)
}
@@ -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
}
}
}
@@ -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
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="crash_logs" path="crash-logs/" />
</paths>
@@ -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),
)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.nativecrashlogs.NativeCrashLogsModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
import NativeCrashLogs from './src/NativeCrashLogsModule';
export default NativeCrashLogs;
+3
View File
@@ -0,0 +1,3 @@
{
"name": "native-crash-logs"
}
@@ -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<void>;
recordJsCrash(stackTrace: string): void;
getPendingCrash(): Promise<string | null>;
clearPendingCrash(): Promise<void>;
getDebugInfo(): Promise<NativeCrashDebugInfo>;
restartApp(): void;
};
export default requireOptionalNativeModule<NativeCrashLogsModule>(
'NativeCrashLogs',
);
+1
View File
@@ -148,6 +148,7 @@
"reactNativeDirectoryCheck": {
"exclude": [
"native-file",
"native-crash-logs",
"nitro-tts",
"native-volume-button-listener",
"native-zip-archive",
@@ -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<ErrorFallbackProps> = ({
resetError,
}) => {
const theme = useTheme();
const [isSharing, setIsSharing] = useState(false);
const fallbackGetString = (
key: Parameters<typeof getString>[0],
@@ -34,22 +36,42 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
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 (
<SafeAreaView
style={[styles.mainCtn, { backgroundColor: theme.background }]}
@@ -65,7 +87,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
<Text style={[styles.errorDesc, { color: theme.onSurface }]}>
{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.',
)}
</Text>
<Text
@@ -83,16 +105,18 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
</View>
<List.Divider theme={theme} />
<Button
onPress={handleCopyStackTrace}
disabled={isSharing}
loading={isSharing}
onPress={handleShareCrashLogs}
title={fallbackGetString(
'errorBoundary.copyStackTrace',
'Copy stack trace',
'errorBoundary.shareCrashLogs',
'Share crash logs',
)}
style={styles.copyButtonCtn}
style={styles.shareButtonCtn}
mode="outlined"
/>
<Button
onPress={resetError}
onPress={handleRestart}
title={fallbackGetString(
'errorBoundary.restart',
'Restart the application',
@@ -121,7 +145,7 @@ const styles = StyleSheet.create({
margin: 16,
marginBottom: 32,
},
copyButtonCtn: {
shareButtonCtn: {
margin: 16,
marginBottom: 8,
},
@@ -0,0 +1,35 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { getPendingCrash } from '@services/crashLogs';
import { ErrorFallback } from './AppErrorBoundary';
const CrashRecoveryGate = ({ children }: PropsWithChildren) => {
const [pendingCrash, setPendingCrash] = useState<string | null>();
useEffect(() => {
getPendingCrash()
.then(crash => {
setPendingCrash(crash);
if (crash) {
SplashScreen.hideAsync();
}
})
.catch(() => setPendingCrash(null));
}, []);
const crashError = useMemo(() => {
if (!pendingCrash) return null;
const error = new Error('LNReader stopped unexpectedly on its last run.');
error.stack = pendingCrash;
return error;
}, [pendingCrash]);
if (pendingCrash === undefined) return null;
if (crashError) {
return <ErrorFallback error={crashError} resetError={() => null} />;
}
return children;
};
export default CrashRecoveryGate;
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor } from '@test-utils';
import { ErrorFallback } from '../AppErrorBoundary';
const mockShareCrashLogs = jest.fn();
const mockRestartApplication = jest.fn();
const mockTheme = {
background: '#ffffff',
onSurface: '#000000',
onSurfaceVariant: '#333333',
surfaceVariant: '#eeeeee',
outlineVariant: '#cccccc',
primary: '#0061a4',
};
jest.mock('@hooks/persisted/useTheme', () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
useTheme: () => mockTheme,
}));
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockTheme,
}));
jest.mock('react-native-device-info', () => ({
__esModule: true,
default: {},
}));
jest.mock('@screens/novel/NovelContext', () => ({
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('react-native-safe-area-context', () => {
const ReactModule = require('react');
const { View } = jest.requireActual('react-native');
const frame = { height: 800, width: 400, x: 0, y: 0 };
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
return {
SafeAreaFrameContext: ReactModule.createContext(frame),
SafeAreaInsetsContext: ReactModule.createContext(insets),
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
ReactModule.createElement(View, props, children),
initialWindowMetrics: { frame, insets },
useSafeAreaFrame: () => frame,
useSafeAreaInsets: () => insets,
};
});
jest.mock('@services/crashLogs', () => ({
shareCrashLogs: (error: Error) => mockShareCrashLogs(error),
restartApplication: (fallback: () => void) =>
mockRestartApplication(fallback),
}));
describe('ErrorFallback', () => {
beforeEach(() => {
mockShareCrashLogs.mockResolvedValue(undefined);
mockRestartApplication.mockResolvedValue(undefined);
});
it('shares a diagnostic report for the displayed error', async () => {
const error = new Error('boom');
render(<ErrorFallback error={error} resetError={jest.fn()} />);
fireEvent.press(screen.getByText('Share crash logs'));
await waitFor(() => expect(mockShareCrashLogs).toHaveBeenCalledWith(error));
});
it('uses the native restart flow', async () => {
const resetError = jest.fn();
render(<ErrorFallback error={new Error('boom')} resetError={resetError} />);
fireEvent.press(screen.getByText('Restart the application'));
await waitFor(() =>
expect(mockRestartApplication).toHaveBeenCalledWith(resetError),
);
});
});
+9 -2
View File
@@ -22,6 +22,7 @@
"dataManagement": "Data Management",
"deleteReadChapters": "Delete read chapters",
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
"diagnostics": "Diagnostics",
"disabled": "Disabled",
"dnsOverHttps": "DNS over HTTPS (DoH)",
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
@@ -37,7 +38,10 @@
"recreateDBIndexesToast": "Recreated DB indexes",
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
"restartRequired": "Restart LNReader to apply this change."
"restartRequired": "Restart LNReader to apply this change.",
"shareCrashLogs": "Share crash logs",
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
"shareCrashLogsFailed": "Failed to create crash logs."
},
"appearance": "Appearance",
"appearanceScreen": {
@@ -391,8 +395,11 @@
},
"errorBoundary": {
"title": "An Unexpected Error Occurred",
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
"copyStackTrace": "Copy stack trace",
"shareCrashLogs": "Share crash logs",
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
"restart": "Restart the application"
},
"generalSettings": "General",
+7
View File
@@ -23,6 +23,7 @@ export interface StringMap {
'advancedSettingsScreen.dataManagement': 'string';
'advancedSettingsScreen.deleteReadChapters': 'string';
'advancedSettingsScreen.deleteReadChaptersDialogTitle': 'string';
'advancedSettingsScreen.diagnostics': 'string';
'advancedSettingsScreen.disabled': 'string';
'advancedSettingsScreen.dnsOverHttps': 'string';
'advancedSettingsScreen.dnsOverHttpsDescription': 'string';
@@ -39,6 +40,9 @@ export interface StringMap {
'advancedSettingsScreen.recreateDBIndexesDialogTitle': 'string';
'advancedSettingsScreen.recreateDBIndexesDesc': 'string';
'advancedSettingsScreen.restartRequired': 'string';
'advancedSettingsScreen.shareCrashLogs': 'string';
'advancedSettingsScreen.shareCrashLogsDescription': 'string';
'advancedSettingsScreen.shareCrashLogsFailed': 'string';
'appearance': 'string';
'appearanceScreen.accentColor': 'string';
'appearanceScreen.alwaysShowNavLabels': 'string';
@@ -338,6 +342,9 @@ export interface StringMap {
'errorBoundary.title': 'string';
'errorBoundary.description': 'string';
'errorBoundary.copyStackTrace': 'string';
'errorBoundary.shareCrashLogs': 'string';
'errorBoundary.shareCrashLogsFailed': 'string';
'errorBoundary.shareCrashLogsFailedWithoutCopy': 'string';
'errorBoundary.restart': 'string';
'generalSettings': 'string';
'generalSettingsScreen.asc': 'string';
+1
View File
@@ -0,0 +1 @@
export const INSTALLED_PLUGINS_KEY = 'INSTALL_PLUGINS';
+2 -1
View File
@@ -14,6 +14,7 @@ import NativeFile from '@modules/native-file';
import { showToast } from '@utils/showToast';
import { LEGACY_PLUGIN_STORAGE, PLUGIN_STORAGE } from '@utils/Storages';
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
import { INSTALLED_PLUGINS_KEY } from './constants';
import {
store,
@@ -87,7 +88,7 @@ const initPlugin = (pluginId: string, rawCode: string) => {
};
const plugins: Record<string, Plugin | undefined> = {};
export const INSTALLED_PLUGINS_KEY = 'INSTALL_PLUGINS';
export { INSTALLED_PLUGINS_KEY } from './constants';
const PLUGIN_FILES = ['custom.js', 'custom.css', 'index.js'] as const;
// v2.1.0 stored plugin bundles in getExternalFilesDir(), which can be unavailable
@@ -21,9 +21,11 @@ import CookieManager from '@preeternal/react-native-cookie-manager';
import { store } from '@plugins/helpers/storage';
import NativeDoh, { DohProviderId } from '@modules/native-doh';
import DohProviderDialog, { DOH_PROVIDERS } from './DohProviderDialog';
import { shareCrashLogs } from '@services/crashLogs';
const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
const theme = useTheme();
const [isSharingCrashLogs, setIsSharingCrashLogs] = useState(false);
const clearCookies = () => {
CookieManager.clearAll();
store.clearAll();
@@ -54,6 +56,17 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
}
};
const handleShareCrashLogs = async () => {
setIsSharingCrashLogs(true);
try {
await shareCrashLogs();
} catch (error) {
showToast(getString('advancedSettingsScreen.shareCrashLogsFailed'));
} finally {
setIsSharingCrashLogs(false);
}
};
const {
value: deleteReadChaptersDialog,
setTrue: showDeleteReadChaptersDialog,
@@ -84,6 +97,20 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
theme={theme}
/>
<ScrollView>
<List.Section>
<List.SubHeader theme={theme}>
{getString('advancedSettingsScreen.diagnostics')}
</List.SubHeader>
<List.Item
title={getString('advancedSettingsScreen.shareCrashLogs')}
description={getString(
'advancedSettingsScreen.shareCrashLogsDescription',
)}
disabled={isSharingCrashLogs}
onPress={handleShareCrashLogs}
theme={theme}
/>
</List.Section>
<List.Section>
<List.SubHeader theme={theme}>
{getString('advancedSettingsScreen.dataManagement')}
@@ -0,0 +1,49 @@
import { buildCrashReport, CrashReportData } from '../buildCrashReport';
const reportData: CrashReportData = {
generatedAt: '2026-09-14T00:00:00.000Z',
application: {
packageName: 'com.example.LNReader',
version: '2.1.3',
buildNumber: '42',
buildType: 'Release',
commitSha: 'abc123',
releaseDate: '2026-09-14',
jsEngine: 'Hermes',
},
device: {
systemName: 'Android',
systemVersion: '16',
apiLevel: 36,
buildId: 'build',
brand: 'brand',
manufacturer: 'manufacturer',
model: 'model',
deviceId: 'device',
supportedAbis: ['arm64-v8a'],
webViewVersion: '140.0',
},
problematicPlugins: [],
exception: 'Error: boom',
};
describe('buildCrashReport', () => {
it('formats application, device, plugin, and exception sections', () => {
const report = buildCrashReport(reportData);
expect(report).toContain('[Application]');
expect(report).toContain('Version: 2.1.3 (42)');
expect(report).toContain('WebView: 140.0');
expect(report).toContain('[Problematic plugins]\nNone detected.');
expect(report).toContain('[Current exception]\nError: boom');
});
it('does not duplicate an identical current and pending crash', () => {
const report = buildCrashReport({
...reportData,
pendingCrash: reportData.exception,
});
expect(report).not.toContain('[Previous fatal crash]');
});
});
@@ -0,0 +1,41 @@
import { PluginItem } from '@plugins/types';
import { getProblematicPlugins } from '../pluginDiagnostics';
const plugin = (overrides: Partial<PluginItem> = {}): PluginItem => ({
id: 'source',
name: 'Source',
site: 'https://example.com',
lang: 'English',
version: '1.0.0',
url: 'https://example.com/plugin.js',
iconUrl: 'https://example.com/icon.png',
...overrides,
});
describe('getProblematicPlugins', () => {
it('includes plugins with updates', () => {
expect(
getProblematicPlugins(
[plugin({ hasUpdate: true })],
[plugin({ version: '1.1.0' })],
),
).toEqual([
expect.objectContaining({
id: 'source',
availableVersion: '1.1.0',
statuses: ['Update available'],
}),
]);
});
it('identifies plugins missing from a populated repository snapshot', () => {
expect(
getProblematicPlugins([plugin()], [plugin({ id: 'different-source' })])[0]
.statuses,
).toContain('Not present in the latest repository snapshot');
});
it('does not mark every plugin missing when no snapshot exists', () => {
expect(getProblematicPlugins([plugin()], [])).toEqual([]);
});
});
@@ -0,0 +1,87 @@
import { PluginDiagnostic } from './pluginDiagnostics';
export type CrashReportData = {
generatedAt: string;
application: {
packageName: string;
version: string;
buildNumber: string;
buildType: string;
commitSha: string;
releaseDate: string;
jsEngine: string;
};
device: {
systemName: string;
systemVersion: string;
apiLevel: number;
buildId: string;
brand: string;
manufacturer: string;
model: string;
deviceId: string;
supportedAbis: string[];
webViewVersion: string | null;
};
problematicPlugins: PluginDiagnostic[];
exception?: string;
pendingCrash?: string;
};
const formatPlugins = (plugins: readonly PluginDiagnostic[]) => {
if (plugins.length === 0) return 'None detected.';
return plugins
.map(plugin => {
const availableVersion = plugin.availableVersion
? ` / Available: ${plugin.availableVersion}`
: '';
return [
`- ${plugin.name} (${plugin.id}, ${plugin.language})`,
` Installed: ${plugin.installedVersion}${availableVersion}`,
` Status: ${plugin.statuses.join(', ')}`,
].join('\n');
})
.join('\n');
};
export const buildCrashReport = (data: CrashReportData): string => {
const sections = [
'LNReader diagnostic report',
`Generated: ${data.generatedAt}`,
'',
'[Application]',
`Package: ${data.application.packageName}`,
`Version: ${data.application.version} (${data.application.buildNumber})`,
`Build type: ${data.application.buildType}`,
`Commit: ${data.application.commitSha}`,
`Release date: ${data.application.releaseDate}`,
`JavaScript engine: ${data.application.jsEngine}`,
'',
'[Device]',
`Operating system: ${data.device.systemName} ${data.device.systemVersion} (SDK ${data.device.apiLevel}; build ${data.device.buildId})`,
`Brand: ${data.device.brand}`,
`Manufacturer: ${data.device.manufacturer}`,
`Model: ${data.device.model} (${data.device.deviceId})`,
`ABIs: ${data.device.supportedAbis.join(', ') || 'Unknown'}`,
`WebView: ${data.device.webViewVersion ?? 'Unknown'}`,
'',
'[Problematic plugins]',
formatPlugins(data.problematicPlugins),
];
if (data.exception) {
sections.push('', '[Current exception]', data.exception);
}
if (data.pendingCrash && data.pendingCrash !== data.exception) {
sections.push('', '[Previous fatal crash]', data.pendingCrash);
}
sections.push(
'',
'[Privacy notice]',
'This report does not intentionally include library data, cookies, plugin settings, or browsing history. Logcat may still contain data written by app or system components; review the file before sending it.',
);
return sections.join('\n');
};
@@ -0,0 +1,9 @@
import { getErrorChainMessages } from '@utils/error';
export const formatException = (error: unknown): string | undefined => {
if (error === undefined || error === null) return undefined;
if (!(error instanceof Error)) return String(error);
const messages = getErrorChainMessages(error).join('\n\nCaused by: ');
return `${messages}\n\n${error.stack ?? ''}`.trim();
};
+78
View File
@@ -0,0 +1,78 @@
import DeviceInfo from 'react-native-device-info';
import NativeCrashLogs from '@modules/native-crash-logs';
import { INSTALLED_PLUGINS_KEY } from '@plugins/constants';
import { PluginItem } from '@plugins/types';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import { BUILD_TYPE, GIT_HASH, RELEASE_DATE } from '@env';
import { version } from '../../../package.json';
import { buildCrashReport } from './buildCrashReport';
import { formatException } from './formatException';
import { getProblematicPlugins } from './pluginDiagnostics';
import { getPendingCrash } from './nativeCrashState';
export {
clearPendingCrash,
getPendingCrash,
restartApplication,
} from './nativeCrashState';
const AVAILABLE_PLUGINS_KEY = 'AVAILABLE_PLUGINS';
const readPlugins = (key: string): PluginItem[] => {
try {
return getMMKVObject<PluginItem[]>(key) ?? [];
} catch {
return [];
}
};
export const shareCrashLogs = async (error?: unknown): Promise<void> => {
if (!NativeCrashLogs) {
throw new Error('Crash log sharing is unavailable in this build.');
}
const [manufacturer, buildId, apiLevel, supportedAbis, nativeDebugInfo] =
await Promise.all([
DeviceInfo.getManufacturer(),
DeviceInfo.getBuildId(),
DeviceInfo.getApiLevel(),
DeviceInfo.supportedAbis(),
NativeCrashLogs.getDebugInfo().catch(() => ({ webViewVersion: null })),
]);
const pendingCrash = await getPendingCrash().catch(() => null);
const problematicPlugins = getProblematicPlugins(
readPlugins(INSTALLED_PLUGINS_KEY),
readPlugins(AVAILABLE_PLUGINS_KEY),
);
const report = buildCrashReport({
generatedAt: new Date().toISOString(),
application: {
packageName: DeviceInfo.getBundleId(),
version,
buildNumber: DeviceInfo.getBuildNumber(),
buildType: BUILD_TYPE || 'Unknown',
commitSha: GIT_HASH || 'Unknown',
releaseDate: RELEASE_DATE || 'Unknown',
jsEngine: 'HermesInternal' in globalThis ? 'Hermes' : 'Unknown',
},
device: {
systemName: DeviceInfo.getSystemName(),
systemVersion: DeviceInfo.getSystemVersion(),
apiLevel,
buildId,
brand: DeviceInfo.getBrand(),
manufacturer,
model: DeviceInfo.getModel(),
deviceId: DeviceInfo.getDeviceId(),
supportedAbis,
webViewVersion: nativeDebugInfo.webViewVersion,
},
problematicPlugins,
exception: formatException(error),
pendingCrash: pendingCrash ?? undefined,
});
await NativeCrashLogs.shareCrashLogs(report, 'E');
};
@@ -0,0 +1,31 @@
import { recordFatalJsCrash } from './nativeCrashState';
type GlobalErrorHandler = (error: unknown, isFatal?: boolean) => void;
type ErrorUtilsApi = {
getGlobalHandler(): GlobalErrorHandler;
setGlobalHandler(handler: GlobalErrorHandler): void;
};
let installed = false;
export const installJsCrashHandler = (): void => {
if (installed) return;
const errorUtils = (
globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsApi }
).ErrorUtils;
if (!errorUtils) return;
const defaultHandler = errorUtils.getGlobalHandler();
errorUtils.setGlobalHandler((error, isFatal) => {
if (isFatal) {
try {
recordFatalJsCrash(error);
} catch {
// Never prevent React Native's default fatal error handling.
}
}
defaultHandler(error, isFatal);
});
installed = true;
};
@@ -0,0 +1,33 @@
import NativeCrashLogs from '@modules/native-crash-logs';
import { formatException } from './formatException';
export const getPendingCrash = async (): Promise<string | null> => {
return (await NativeCrashLogs?.getPendingCrash()) ?? null;
};
export const clearPendingCrash = async (): Promise<void> => {
await NativeCrashLogs?.clearPendingCrash();
};
export const restartApplication = async (
fallback?: () => void,
): Promise<void> => {
if (!NativeCrashLogs) {
fallback?.();
return;
}
try {
await NativeCrashLogs.clearPendingCrash();
} finally {
NativeCrashLogs.restartApp();
}
};
export const recordFatalJsCrash = (error: unknown): void => {
const exception = formatException(error);
if (!exception || !NativeCrashLogs) return;
NativeCrashLogs.recordJsCrash(
`Fatal JavaScript crash captured: ${new Date().toISOString()}\n\n${exception}`,
);
};
@@ -0,0 +1,44 @@
import { PluginItem } from '@plugins/types';
export type PluginDiagnostic = {
id: string;
name: string;
language: string;
installedVersion: string;
availableVersion?: string;
statuses: string[];
};
export const getProblematicPlugins = (
installedPlugins: readonly PluginItem[],
availablePlugins: readonly PluginItem[],
): PluginDiagnostic[] => {
const availableById = new Map(
availablePlugins.map(plugin => [plugin.id, plugin]),
);
const hasRepositorySnapshot = availablePlugins.length > 0;
return installedPlugins.flatMap(plugin => {
const availablePlugin = availableById.get(plugin.id);
const statuses: string[] = [];
if (plugin.hasUpdate) {
statuses.push('Update available');
}
if (hasRepositorySnapshot && !availablePlugin) {
statuses.push('Not present in the latest repository snapshot');
}
if (statuses.length === 0) return [];
return [
{
id: plugin.id,
name: plugin.name,
language: plugin.lang,
installedVersion: plugin.version,
availableVersion: availablePlugin?.version,
statuses,
},
];
});
};