From ae6bc3dd99462aa5b0cc998fc676006d2655b3de Mon Sep 17 00:00:00 2001 From: usr577 Date: Thu, 13 Aug 2026 17:33:07 +0200 Subject: [PATCH 1/3] crash report: stop the crash reporter from crashing on its own report Opening the crash screen could kill the app a second time, so the user saw the app vanish with no report at all: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.Object.toString()' on a null object reference at java.lang.Throwable.printEnclosedStackTrace(Throwable.java:727) at CrashReportData.toString(CrashReportData.kt:29) CrashReportData held the live Throwable and printed it later, from CrashReportActivity. But OmsUncaughtExceptionHandler passes the object as an Intent extra and then lets the process die, so it has to survive Java serialization and a process restart - the pids in logcat differ between the original crash and the crash screen. A Throwable does not survive that reliably: it can come back with a null entry in its frame array, and printing it then throws. The stack trace is now rendered to a String in the crashing process, where the Throwable is still intact, and only that String is serialized. The fallback in renderStackTrace is a constant rather than an interpolation, because formatting the throwable would call toString() on the object that just failed to print and could throw a second time. Also bounds the logcat at read time via "logcat -t " for the crash path. The report crosses a Binder transaction with a ~1 MB budget, and an unbounded "logcat -b all -d" can exceed it on a busy device - TransactionTooLargeException thrown from inside the uncaught exception handler means no crash screen at all. Bounding at read time rather than truncating afterwards also avoids materialising a multi-MB string twice while the process is already failing, which matters when the original crash was itself an OutOfMemoryError. The bound is opt-in via a constructor parameter, so the diagnostic exports in QRScreen, which write straight to a file and have no Binder limit, keep the complete log. --- .../java/com/onemoresecret/CrashReportData.kt | 24 ++++++++++++++----- .../OmsUncaughtExceptionHandler.kt | 3 ++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/onemoresecret/CrashReportData.kt b/app/src/main/java/com/onemoresecret/CrashReportData.kt index b44a678..2c2719f 100644 --- a/app/src/main/java/com/onemoresecret/CrashReportData.kt +++ b/app/src/main/java/com/onemoresecret/CrashReportData.kt @@ -10,8 +10,9 @@ import java.io.PrintWriter import java.io.Serializable import java.io.StringWriter -class CrashReportData(private val throwable: Throwable?) : Serializable { - private val logcat: String? = getLogcat() +class CrashReportData(throwable: Throwable?, logcatTailLines: Int? = null) : Serializable { + private val stackTrace: String? = throwable?.let { renderStackTrace(it) } + private val logcat: String? = getLogcat(logcatTailLines) fun toString(includeLogcat: Boolean): String? { try { @@ -24,9 +25,9 @@ class CrashReportData(private val throwable: Throwable?) : Serializable { BuildConfig.FLAVOR ) ) - if (throwable != null) { + if (stackTrace != null) { pw.println("\n----- STACK TRACE -----") - throwable.printStackTrace(pw) + pw.println(stackTrace) } pw.println("\n----- DEVICE -----") pw.println("Brand: " + Build.BRAND) @@ -55,8 +56,19 @@ class CrashReportData(private val throwable: Throwable?) : Serializable { companion object { private val TAG: String = CrashReportData::class.java.simpleName - fun getLogcat(): String? { - return getProcessOutput("logcat", "-b", "all", "-d") + private fun renderStackTrace(t: Throwable): String = try { + StringWriter().use { sw -> + PrintWriter(sw).use { pw -> t.printStackTrace(pw) } + sw.toString() + } + } catch (_: Throwable) { + "(stack trace unavailable)" + } + + fun getLogcat(tailLines: Int? = null): String? = if (tailLines == null) { + getProcessOutput("logcat", "-b", "all", "-d") + } else { + getProcessOutput("logcat", "-b", "all", "-d", "-t", tailLines.toString()) } fun getProcessOutput(vararg sArr: String?): String? { diff --git a/app/src/main/java/com/onemoresecret/OmsUncaughtExceptionHandler.kt b/app/src/main/java/com/onemoresecret/OmsUncaughtExceptionHandler.kt index 5aee64e..635680d 100644 --- a/app/src/main/java/com/onemoresecret/OmsUncaughtExceptionHandler.kt +++ b/app/src/main/java/com/onemoresecret/OmsUncaughtExceptionHandler.kt @@ -9,7 +9,7 @@ class OmsUncaughtExceptionHandler(private val activity: Activity) : private val existingHandler = Thread.getDefaultUncaughtExceptionHandler() override fun uncaughtException(t: Thread, e: Throwable) { - val crashReportData = CrashReportData(e) + val crashReportData = CrashReportData(e, LOGCAT_TAIL_LINES) val intent = Intent(activity.applicationContext, CrashReportActivity::class.java).apply{ putExtra(EXTRA_CRASH_REPORT, crashReportData) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) @@ -25,5 +25,6 @@ class OmsUncaughtExceptionHandler(private val activity: Activity) : companion object { const val EXTRA_CRASH_REPORT: String = "CRASH_REPORT" + private const val LOGCAT_TAIL_LINES = 2000 } } From 1ff689e85fadc08f7b747c95ff46b21c92d1ac07 Mon Sep 17 00:00:00 2001 From: usr577 Date: Thu, 13 Aug 2026 19:51:02 +0200 Subject: [PATCH 2/3] qr: close dropped camera frames and reset mlTask when a task is cancelled Three paths through analyze() returned without closing the ImageProxy, and mlTask was only ever cleared by the success and failure listeners. Measured on a device before changing anything: across 309 analyze() calls the "already busy" and "null mediaImage" branches were never taken once. With STRATEGY_KEEP_ONLY_LATEST and the default image queue depth CameraX does not re-enter analyze() until the current frame is closed, and the old code cleared mlTask immediately before closing it, so neither branch is reachable as the use case is configured today. They are guarded anyway because nothing enforces that - a setImageQueueDepth() call or a different backpressure strategy makes them live - and the failure mode is silent: leaked buffers starve the pool, the analyzer stops receiving frames and scanning dies while the preview carries on rendering normally. The reachable gap is task cancellation. Both addOnSuccessListener and addOnFailureListener skip a cancelled Task, so if ML Kit cancels one - tearing the detector down on a lifecycle transition, for instance - mlTask stays non-null forever and its frame is never closed, permanently killing scanning for that Analyzer instance. mlTask is now cleared from addOnCompleteListener, which also runs for cancellation. Not reproduced: I have not observed a cancelled Task in the wild, this is from the Tasks API contract that neither listener fires for one. The foss analyzer is unaffected - it wraps the frame in imageProxy.use {}. --- .../java/com/onemoresecret/qr/Analyzer.kt | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/app/src/standard/java/com/onemoresecret/qr/Analyzer.kt b/app/src/standard/java/com/onemoresecret/qr/Analyzer.kt index 3acf408..f25d836 100644 --- a/app/src/standard/java/com/onemoresecret/qr/Analyzer.kt +++ b/app/src/standard/java/com/onemoresecret/qr/Analyzer.kt @@ -17,36 +17,46 @@ class Analyzer { @OptIn(ExperimentalGetImage::class) fun analyze(imageProxy: ImageProxy, onQRCodeFound: Consumer) { - if (mlTask != null) return + if (mlTask != null) { + imageProxy.close() + return + } val mediaImage = imageProxy.image - if (mediaImage != null) { - if (barcodeScanner == null) { - barcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() + if (mediaImage == null) { + imageProxy.close() + } else { + try { + if (barcodeScanner == null) { + barcodeScanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + } + val inputImage = InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees ) - } - val inputImage = InputImage.fromMediaImage( - mediaImage, - imageProxy.imageInfo.rotationDegrees - ) - mlTask = barcodeScanner!!.process(inputImage) - .addOnSuccessListener { barcodes -> - mlTask = null - imageProxy.close() - for (barcode in barcodes) { - onQRCodeFound.accept(barcode.rawValue) + mlTask = barcodeScanner!!.process(inputImage) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + onQRCodeFound.accept(barcode.rawValue) + } } - } - .addOnFailureListener { e -> - mlTask = null - imageProxy.close() - e.printStackTrace() - } + .addOnFailureListener { e -> + e.printStackTrace() + } + .addOnCompleteListener { + mlTask = null + imageProxy.close() + } + } catch (t: Throwable) { + imageProxy.close() + throw t + } } } } From 2fb7a50cfa0d63170fbce7e6817805aad76bf004 Mon Sep 17 00:00:00 2001 From: usr577 Date: Thu, 13 Aug 2026 19:51:02 +0200 Subject: [PATCH 3/3] crash report: replace the mailto-only Send with the system share sheet The ACTION_SEND intent carried a "mailto:" data URI and no MIME type. ACTION_SEND is matched on MIME type, so conventional mail filters miss that combination. Measured on a device with Gmail and K-9 installed: $ adb shell cmd package query-activities -a android.intent.action.SEND -d "mailto:" No activities found $ adb shell cmd package query-activities -a android.intent.action.SEND -t text/plain 126 matches Outlook does appear to declare a filter broad enough to match it, so on devices with Outlook installed the primary path worked. Everywhere else it threw ActivityNotFoundException and fell through to the ACTION_SENDTO fallback - which carries no attachment at all and inlines the entire report in EXTRA_TEXT instead, risking TransactionTooLargeException once the logcat is included. The black screen came from somewhere else: every branch called finish() + exitProcess(0) immediately after startActivity(). With two mail apps installed and no default set, ACTION_SENDTO raises a disambiguation chooser owned by the calling task - and killing the process takes the chooser down with it before anything can be picked. The same call also meant the "could not send" toast was never on screen long enough to be read. Changes: - ACTION_SEND sets type = "text/plain" and drops the data URI; "mailto:" moves to the ACTION_SENDTO fallback where it belongs. EXTRA_EMAIL still pre-fills the maintainer address when a mail app is picked. - Added a Copy button. This is how the crash reports behind the two fixes in #35 were retrieved from the device. - Nothing but Dismiss ends the process, so a cancelled or failed share no longer loses the report. - The report is written to cacheDir/tmp/crash/ instead of cacheDir/tmp. Six code paths call OmsFileProvider.purgeTmp unconditionally, and share targets such as Gmail resolve the content URI lazily at send time, so the attachment used to disappear as soon as the app was reopened. purgeTmp does not recurse into directories and filepaths.xml already exposes all of tmp/. - The clipboard write sets ClipDescription.EXTRA_IS_SENSITIVE, matching the other clipboard writes in the app, since the report can carry the logcat. - FLAG_SECURE on the window, matching MainActivity, for the same reason. - A missing report extra now finishes the activity instead of leaving a blank screen with no way out. - The report is inlined in EXTRA_TEXT only below 128 KB; above that the attachment carries it, so including the logcat cannot blow the transaction limit. - Removed R.string.could_not_send_email - its only reference was the deleted fallback, and Intent.createChooser always resolves. The report is written through OmsFileProvider.create with a new optional subdirectory argument rather than a direct FileProvider.getUriForFile call, so the authority string stays in one place as everywhere else in the app. One deliberate trade-off, called out so it is easy to reverse if you disagree: below 128 KB the report goes out both inline in EXTRA_TEXT and as the attachment, so a mail client shows the whole report in the body as well as attaching crash_report.txt. That is redundant to read, but a lot of share targets - chat apps, note apps, clipboard managers - silently ignore EXTRA_STREAM and would otherwise receive an empty report. Dropping the inline copy is a two-line change if you would rather the mail case stay clean. --- .../com/onemoresecret/CrashReportActivity.kt | 129 ++++++++++-------- .../java/com/onemoresecret/OmsFileProvider.kt | 9 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/onemoresecret/CrashReportActivity.kt b/app/src/main/java/com/onemoresecret/CrashReportActivity.kt index 8d2ff23..5d2610c 100644 --- a/app/src/main/java/com/onemoresecret/CrashReportActivity.kt +++ b/app/src/main/java/com/onemoresecret/CrashReportActivity.kt @@ -1,8 +1,14 @@ package com.onemoresecret -import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle +import android.os.PersistableBundle +import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -14,38 +20,43 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import com.onemoresecret.CrashReportData -import com.onemoresecret.OmsFileProvider -import com.onemoresecret.R import com.onemoresecret.composable.OneMoreSecretTheme import java.io.IOException import java.nio.charset.StandardCharsets import java.nio.file.Files import kotlin.system.exitProcess +private const val MAX_INLINE_REPORT_LENGTH = 128 * 1024 class CrashReportActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + //prohibit screenshots + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + Thread { OmsFileProvider.purgeTmp(this) }.start() @Suppress("DEPRECATION") val crashReportData = intent.getSerializableExtra(OmsUncaughtExceptionHandler.EXTRA_CRASH_REPORT) as? CrashReportData - + + if (crashReportData == null) { + finish() + return + } + setContent { OneMoreSecretTheme { - if (crashReportData != null) { - CrashReport( - crashReportData = crashReportData, - onDismiss = { - finish() - exitProcess(0) - } - ) - } + CrashReport( + crashReportData = crashReportData, + onDismiss = { + finish() + exitProcess(0) + } + ) } } } @@ -105,60 +116,64 @@ fun CrashReport( Text("Dismiss") } Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = { + copyToClipboard(context, reportText) + }) { + Text(stringResource(R.string.copy)) + } + Spacer(modifier = Modifier.width(8.dp)) Button(onClick = { - sendEmail(context, crashReportData, includeLogcat, onDismiss) + shareReport(context, reportText) }) { - Text("Send") + Text(stringResource(R.string.share)) } } } } -// Extracted logic for sending the email -private fun sendEmail( - context: android.content.Context, - crashReportData: CrashReportData, - includeLogcat: Boolean, - onComplete: () -> Unit -) { - val contactEmail = context.getString(R.string.contact_email) +private fun copyToClipboard(context: Context, report: String) { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText("oneMoreSecret", report) - fun createBaseIntent(action: String): Intent { - return Intent(action).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.crash_email_subject)) - putExtra(Intent.EXTRA_EMAIL, arrayOf(contactEmail)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clipData.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } } - try { - val crashReport = crashReportData.toString(includeLogcat) - val fileRecord = OmsFileProvider.create(context, "crash_report.txt", false) - Files.write(fileRecord.path, crashReport!!.toByteArray(StandardCharsets.UTF_8)) + clipboardManager.setPrimaryClip(clipData) + Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() +} - val intentSend = createBaseIntent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, fileRecord.uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - putExtra(Intent.EXTRA_TEXT, context.getString(R.string.crash_email_body)) - } +// Extracted logic for sharing the report +private fun shareReport(context: Context, report: String) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.crash_email_subject)) + putExtra(Intent.EXTRA_TITLE, context.getString(R.string.crash_email_subject)) + putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.contact_email))) + } - try { - context.startActivity(intentSend) - onComplete() - } catch (_: ActivityNotFoundException) { - try { - val intentSendTo = createBaseIntent(Intent.ACTION_SENDTO).apply { - putExtra(Intent.EXTRA_TEXT, crashReport) - } - context.startActivity(intentSendTo) - onComplete() - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, - context.getString(R.string.could_not_send_email), Toast.LENGTH_LONG).show() - onComplete() - } - } + val attached = try { + val fileRecord = OmsFileProvider.create(context, "crash_report.txt", false, "crash") + Files.write(fileRecord.path, report.toByteArray(StandardCharsets.UTF_8)) + sendIntent.putExtra(Intent.EXTRA_STREAM, fileRecord.uri) + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + true } catch (ex: IOException) { - ex.printStackTrace() + Util.printStackTrace(ex) + false } -} \ No newline at end of file + + val body = context.getString(R.string.crash_email_body) + sendIntent.putExtra( + Intent.EXTRA_TEXT, + when { + report.length <= MAX_INLINE_REPORT_LENGTH -> "$body\n\n$report" + attached -> body + else -> "$body\n\n" + report.take(MAX_INLINE_REPORT_LENGTH) + } + ) + + context.startActivity(Intent.createChooser(sendIntent, null)) +} diff --git a/app/src/main/java/com/onemoresecret/OmsFileProvider.kt b/app/src/main/java/com/onemoresecret/OmsFileProvider.kt index c9d7653..66160ac 100644 --- a/app/src/main/java/com/onemoresecret/OmsFileProvider.kt +++ b/app/src/main/java/com/onemoresecret/OmsFileProvider.kt @@ -18,8 +18,13 @@ class OmsFileProvider : FileProvider() { @JvmStatic @Throws(IOException::class) - fun create(ctx: Context, filename: String?, deleteOnExit: Boolean): FileRecord { - val dir = File(ctx.cacheDir, "tmp") + fun create( + ctx: Context, + filename: String?, + deleteOnExit: Boolean, + subdir: String? = null + ): FileRecord { + val dir = if (subdir == null) File(ctx.cacheDir, "tmp") else File(ctx.cacheDir, "tmp/$subdir") assert( dir.exists() || dir.mkdirs() //otherwise something went wrong ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af384d9..e6d86af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Similar… Encrypt With: Share + Copy Decrypted Message Authentication was successful Cannot apply layout filter - no layout selected @@ -201,7 +202,6 @@ Ready to unlock oms4web ⚠️ this is a BETA version, see Release Notes 🚀 oms4web is here, give it a try! - Could not send email The report file has been attached. Please share the steps to reproduce the issue. OneMoreSecret crash report 4096 bit RSA