feat: Add Crash Log Collection And Sharing (Closes #11)
This commit is contained in:
@@ -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,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>
|
||||
+57
@@ -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())
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
|
||||
+15
@@ -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]")
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+73
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+163
@@ -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>
|
||||
+26
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import NativeCrashLogs from './src/NativeCrashLogsModule';
|
||||
|
||||
export default NativeCrashLogs;
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const INSTALLED_PLUGINS_KEY = 'INSTALL_PLUGINS';
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user