Skip to content

feat: pluggable crash report upload with state tracking - #4

Open
rayworks wants to merge 1 commit into
masterfrom
feat/crash-report-upload
Open

feat: pluggable crash report upload with state tracking#4
rayworks wants to merge 1 commit into
masterfrom
feat/crash-report-upload

Conversation

@rayworks

@rayworks rayworks commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an opt-in mechanism to upload local crash reports to a backend and track the upload state of each report. Previously the library only captured reports (JVM *_crash.txt / *_exception.txt and native Breakpad core_dump/*.dmp) and surfaced them on-device — there was no egress path and no state beyond the filename suffix.

The library stays backend-agnostic: the host app supplies the transport via CrashReportUploader; the library owns discovery, state, and retry. No INTERNET permission is added to the library manifest.

What's included

  • CrashReportUploader — consumer-implemented upload(File, ReportType): Boolean.
  • Room state store — per-report status (PENDING → UPLOADING → UPLOADED / FAILED), attempt count, timestamps, last error (crashreporter_uploads.db, internal storage).
  • CrashReportScanner — reconciles both report locations with the store on each run; prunes rows for deleted files.
  • CrashUploadWorker (WorkManager) — drains uploadable reports off the crashing thread, with a CONNECTED constraint and exponential-backoff retry up to maxUploadAttempts (default 5).
  • Public API on CrashReporter: setUploader, setUploadEnabled (off by default), setDeleteAfterUpload, setMaxUploadAttempts, uploadPendingReports.
  • Removed leftover System.out.println(">>>…") debug lines from initialize/initNative.
  • docs/upload.md.

Design notes

  • Deferred, not instant-on-crash. A fatal crash is handled on a dying thread and native minidumps are written entirely in C++, so uploads run on the next launch's background pass (or immediately via uploadPendingReports() while the app is running).
  • The uploader is held in memory, so the worker retries when none is registered yet — register it early (e.g. Application.onCreate).

Testing

./gradlew :crashreporter:testDebugUnitTestpassing (11 new Robolectric tests across DAO, scanner, worker; success / false / throw / no-uploader paths). Robolectric pinned to SDK 33 since 4.11.1 has no sandbox for compileSdk 35.

⚠️ The full NDK assembleDebug was not run in this environment — worth a full assemble before release.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added opt-in crash report uploading to configurable backends.
    • Added background uploads with network requirements, retry handling, and configurable attempt limits.
    • Added options to trigger pending uploads and delete reports after successful delivery.
    • Added support for crash reports, exceptions, and native minidumps.
  • Documentation

    • Added guides for building diagnostic tools and configuring crash report uploads.
  • Tests

    • Expanded coverage for report tracking, scanning, uploading, retries, and cleanup.

Add an opt-in mechanism to upload local crash reports (JVM stack traces and
native Breakpad minidumps) through a consumer-supplied interface, tracking the
upload state of each report so nothing is sent twice and failures are retried.

- CrashReportUploader: backend-agnostic transport interface implemented by the
  host app; the library adds no INTERNET permission of its own.
- Room-backed state store (PENDING/UPLOADING/UPLOADED/FAILED + attempt count).
- CrashReportScanner reconciles both report locations (crashReports/*.txt by
  suffix and core_dump/*.dmp) with the store on each run.
- CrashUploadWorker (WorkManager) drains uploadable reports off the crashing
  thread with a network constraint and exponential-backoff retry.
- Public API on CrashReporter: setUploader / setUploadEnabled (off by default) /
  setDeleteAfterUpload / setMaxUploadAttempts / uploadPendingReports.
- Remove leftover System.out.println debug lines from initialize/initNative.
- Robolectric unit tests for the DAO, scanner, and worker; docs/upload.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in crash report upload pipeline. Reports are discovered from disk, tracked in Room, processed by WorkManager through a configurable uploader, retried after failures, and optionally deleted after successful upload. CrashReporter exposes configuration and triggering APIs, with tests and documentation.

Changes

Crash report upload pipeline

Layer / File(s) Summary
Upload contracts and persistence
crashreporter/src/main/java/com/balsikandar/crashreporter/upload/ReportType.kt, crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/*, crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashReportDaoTest.kt
Defines report and lifecycle types, stores upload metadata with Room, and tests insertion, status preservation, retry limits, and selection.
Reconciliation and background uploads
crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportScanner.kt, crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt, crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashReportScannerTest.kt, crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorkerTest.kt, crashreporter/build.gradle, crashreporter/src/test/resources/robolectric.properties
Reconciles report files with database state, uploads eligible records in WorkManager, handles retries and cleanup, and validates scanner and worker behavior.
Configuration and public API
crashreporter/src/main/java/com/balsikandar/crashreporter/CrashReporter.java, crashreporter/src/main/java/com/balsikandar/crashreporter/upload/CrashReportUploader.kt, crashreporter/src/main/java/com/balsikandar/crashreporter/upload/CrashUploadManager.kt, crashreporter/docs/upload.md
Adds uploader registration, enablement, retry and deletion settings, immediate upload triggering, scheduling, and usage documentation.

Breakpad tooling notes

Layer / File(s) Summary
Breakpad tooling references
crashreporter/docs/breadpad.md
Adds links describing Breakpad tool acquisition, building, and dump inspection.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant CrashReporter
  participant CrashUploadManager
  participant WorkManager
  participant CrashUploadWorker
  participant CrashReportUploader
  Application->>CrashReporter: register uploader and enable uploads
  CrashReporter->>CrashUploadManager: configure upload pipeline
  CrashUploadManager->>WorkManager: enqueue constrained worker
  WorkManager->>CrashUploadWorker: execute background upload
  CrashUploadWorker->>CrashReportUploader: upload report
  CrashReportUploader-->>CrashUploadWorker: return success or failure
  CrashUploadWorker-->>WorkManager: return success or retry
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: pluggable crash report uploads with state tracking.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/crash-report-upload

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
crashreporter/docs/breadpad.md (1)

3-7: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the tooling references authoritative and reproducible.

The Breakpad link points to a mutable branch, while the Chapter01 link is a third-party sample with Mac-only prebuilt tools and example-specific paths; its own README directs users to upstream Breakpad for building tools. (github.com) Prefer linking directly to the relevant upstream build/dump-inspection documentation, optionally retaining Chapter01 as an example, and pinning references to a known revision where reproducibility matters.

Suggested documentation update
 ### Fetch the source and build the tools
-https://github.com/google/breakpad
+[Breakpad source and build instructions](https://github.com/google/breakpad)

 ### Check the dump file with tools
-https://github.com/AndroidAdvanceWithGeektime/Chapter01
+[Chapter01 sample tooling](https://github.com/AndroidAdvanceWithGeektime/Chapter01)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crashreporter/docs/breadpad.md` around lines 3 - 7, Update the documentation
references in breadpad.md to link directly to upstream Breakpad build and
dump-inspection documentation instead of relying on the mutable repository
branch and third-party Chapter01 tooling; optionally retain Chapter01 only as an
explicitly labeled example, and pin upstream references to a known revision
where reproducibility is required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crashreporter/build.gradle`:
- Around line 67-77: Change the androidx.test.ext:junit dependency declaration
from implementation to testImplementation in the Gradle dependencies block,
keeping it scoped only to tests and leaving the other dependency configurations
unchanged.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/CrashReporter.java`:
- Around line 50-56: Update the directory creation in initNative to call
mkdirs() instead of mkdir() for coreFolder, ensuring the full crash-report path
is created when parent directories are missing while preserving the existing
existence check and Breakpad initialization.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportDao.kt`:
- Around line 27-35: Update the getUploadable and countUploadable queries to
include UPLOADING alongside PENDING and FAILED, so interrupted uploads are
eligible for retry while preserving the existing maxAttempts filtering and
ordering.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt`:
- Around line 45-50: Update CrashUploadWorker’s upload loop to check isStopped
at the start of each iteration and break before processing further reports; in
its catch block, also break when the exception is an InterruptedException or
isStopped is true, preventing cancellation from being recorded as a retryable
upload failure. Apply both changes at
crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt
lines 45-50 and 68-72.
- Around line 59-62: Update the deleteAfterUpload cleanup in CrashUploadWorker
so dao.deleteByPath(entity.filePath) runs only when file.delete() succeeds or
the file is already absent; retain the database row when deletion fails while
the file still exists.

---

Nitpick comments:
In `@crashreporter/docs/breadpad.md`:
- Around line 3-7: Update the documentation references in breadpad.md to link
directly to upstream Breakpad build and dump-inspection documentation instead of
relying on the mutable repository branch and third-party Chapter01 tooling;
optionally retain Chapter01 only as an explicitly labeled example, and pin
upstream references to a known revision where reproducibility is required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2ead2fd5-73a6-4d8c-a732-221650f2fc21

📥 Commits

Reviewing files that changed from the base of the PR and between a81fa99 and 3900478.

📒 Files selected for processing (18)
  • crashreporter/build.gradle
  • crashreporter/docs/breadpad.md
  • crashreporter/docs/upload.md
  • crashreporter/src/main/java/com/balsikandar/crashreporter/CrashReporter.java
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/CrashReportUploader.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/CrashUploadManager.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/ReportType.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/Converters.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportDao.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportDatabase.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportEntity.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportScanner.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/UploadStatus.kt
  • crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashReportDaoTest.kt
  • crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashReportScannerTest.kt
  • crashreporter/src/test/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorkerTest.kt
  • crashreporter/src/test/resources/robolectric.properties

Comment on lines +67 to +77
// Crash-report upload state tracking + background upload
implementation 'androidx.room:room-runtime:2.6.1'
kapt 'androidx.room:room-compiler:2.6.1'
implementation 'androidx.work:work-runtime:2.9.1'

testImplementation 'junit:junit:4.13.2'
implementation 'androidx.test.ext:junit:1.1.5'
testImplementation 'androidx.room:room-testing:2.6.1'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.work:work-testing:2.9.1'
testImplementation 'androidx.test:core:1.5.0'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

Do not leak test dependencies into the production artifact.

The androidx.test.ext:junit dependency is being added using implementation. Because this is an Android library project, this configuration bundles the test dependency into the compile classpath of any application consuming the library. Change it to testImplementation to restrict it exclusively to the testing scope.

🐛 Proposed fix
     testImplementation 'junit:junit:4.13.2'
-    implementation 'androidx.test.ext:junit:1.1.5'
+    testImplementation 'androidx.test.ext:junit:1.1.5'
     testImplementation 'androidx.room:room-testing:2.6.1'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Crash-report upload state tracking + background upload
implementation 'androidx.room:room-runtime:2.6.1'
kapt 'androidx.room:room-compiler:2.6.1'
implementation 'androidx.work:work-runtime:2.9.1'
testImplementation 'junit:junit:4.13.2'
implementation 'androidx.test.ext:junit:1.1.5'
testImplementation 'androidx.room:room-testing:2.6.1'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.work:work-testing:2.9.1'
testImplementation 'androidx.test:core:1.5.0'
// Crash-report upload state tracking + background upload
implementation 'androidx.room:room-runtime:2.6.1'
kapt 'androidx.room:room-compiler:2.6.1'
implementation 'androidx.work:work-runtime:2.9.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'androidx.test.ext:junit:1.1.5'
testImplementation 'androidx.room:room-testing:2.6.1'
testImplementation 'org.robolectric:robolectric:4.11.1'
testImplementation 'androidx.work:work-testing:2.9.1'
testImplementation 'androidx.test:core:1.5.0'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crashreporter/build.gradle` around lines 67 - 77, Change the
androidx.test.ext:junit dependency declaration from implementation to
testImplementation in the Gradle dependencies block, keeping it scoped only to
tests and leaving the other dependency configurations unchanged.

Comment on lines 50 to 56
private static void initNative(Context context) {
String fileDir = crashReportPath == null ? CrashUtil.getDefaultPath(context) : crashReportPath;
String fileDir = resolveCrashReportRoot(context);
File coreFolder = new File(new File(fileDir), "core_dump");
if (!coreFolder.exists())
coreFolder.mkdir();

System.out.println(">>> core dump path : " + coreFolder.getAbsolutePath());
CrashReporter.initBreakpad(coreFolder.getAbsolutePath());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use mkdirs() to safely handle missing parent directories.

If a custom crashReportSavePath is supplied and its parent directories do not exist yet, mkdir() will fail silently. This would prevent the core_dump folder from being created and minidumps from being saved. Using mkdirs() guarantees the entire path is created.

🛠️ Proposed fix
     private static void initNative(Context context) {
         String fileDir = resolveCrashReportRoot(context);
         File coreFolder = new File(new File(fileDir), "core_dump");
         if (!coreFolder.exists())
-            coreFolder.mkdir();
+            coreFolder.mkdirs();
 
         CrashReporter.initBreakpad(coreFolder.getAbsolutePath());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static void initNative(Context context) {
String fileDir = crashReportPath == null ? CrashUtil.getDefaultPath(context) : crashReportPath;
String fileDir = resolveCrashReportRoot(context);
File coreFolder = new File(new File(fileDir), "core_dump");
if (!coreFolder.exists())
coreFolder.mkdir();
System.out.println(">>> core dump path : " + coreFolder.getAbsolutePath());
CrashReporter.initBreakpad(coreFolder.getAbsolutePath());
private static void initNative(Context context) {
String fileDir = resolveCrashReportRoot(context);
File coreFolder = new File(new File(fileDir), "core_dump");
if (!coreFolder.exists())
coreFolder.mkdirs();
CrashReporter.initBreakpad(coreFolder.getAbsolutePath());
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 51-51: Prevent path traversal
Context: new File(new File(fileDir), "core_dump")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). Security best practice.

(path-traversal-java)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crashreporter/src/main/java/com/balsikandar/crashreporter/CrashReporter.java`
around lines 50 - 56, Update the directory creation in initNative to call
mkdirs() instead of mkdir() for coreFolder, ensuring the full crash-report path
is created when parent directories are missing while preserving the existing
existence check and Breakpad initialization.

Comment on lines +27 to +35
@Query(
"SELECT * FROM crash_reports " +
"WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts " +
"ORDER BY createdAt ASC"
)
fun getUploadable(maxAttempts: Int): List<CrashReportEntity>

@Query("SELECT COUNT(*) FROM crash_reports WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts")
fun countUploadable(maxAttempts: Int): Int

@coderabbitai coderabbitai Bot Jul 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Prevent crash reports from getting permanently stuck in the UPLOADING state.

If the application process is killed while a report is actively in the UPLOADING state, it will remain UPLOADING in the database forever. Because getUploadable and countUploadable only select PENDING and FAILED reports, that crash report will never be retried, resulting in silent data loss.

To seamlessly recover interrupted uploads, include 'UPLOADING' in these queries (assuming WorkManager is configured to prevent concurrent worker instances), or explicitly reset UPLOADING to PENDING at the start of the worker run.

🛠️ Proposed fix
     `@Query`(
         "SELECT * FROM crash_reports " +
-            "WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts " +
+            "WHERE status IN ('PENDING', 'FAILED', 'UPLOADING') AND attemptCount < :maxAttempts " +
             "ORDER BY createdAt ASC"
     )
     fun getUploadable(maxAttempts: Int): List<CrashReportEntity>
 
-    `@Query`("SELECT COUNT(*) FROM crash_reports WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts")
+    `@Query`("SELECT COUNT(*) FROM crash_reports WHERE status IN ('PENDING', 'FAILED', 'UPLOADING') AND attemptCount < :maxAttempts")
     fun countUploadable(maxAttempts: Int): Int
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Query(
"SELECT * FROM crash_reports " +
"WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts " +
"ORDER BY createdAt ASC"
)
fun getUploadable(maxAttempts: Int): List<CrashReportEntity>
@Query("SELECT COUNT(*) FROM crash_reports WHERE status IN ('PENDING', 'FAILED') AND attemptCount < :maxAttempts")
fun countUploadable(maxAttempts: Int): Int
`@Query`(
"SELECT * FROM crash_reports " +
"WHERE status IN ('PENDING', 'FAILED', 'UPLOADING') AND attemptCount < :maxAttempts " +
"ORDER BY createdAt ASC"
)
fun getUploadable(maxAttempts: Int): List<CrashReportEntity>
`@Query`("SELECT COUNT(*) FROM crash_reports WHERE status IN ('PENDING', 'FAILED', 'UPLOADING') AND attemptCount < :maxAttempts")
fun countUploadable(maxAttempts: Int): Int
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportDao.kt`
around lines 27 - 35, Update the getUploadable and countUploadable queries to
include UPLOADING alongside PENDING and FAILED, so interrupted uploads are
eligible for retry while preserving the existing maxAttempts filtering and
ordering.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Items in UPLOADING state shouldn't be counted in the Uploadable set ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -e kt . crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal --exec cat -n {}
echo "---manager---"
fd CrashUploadManager.kt crashreporter/src/main/java/com/balsikandar/crashreporter/upload --exec cat -n {}

Length of output: 17905


@rayworks Good catch, and you're right to push back on that.

Looking more closely at CrashUploadWorker.doWork(), getUploadable() is only queried once at the top of the run, then the loop marks each entity UPLOADING immediately before uploading and finalizes it before moving on — so within a single run there's no risk of an in-flight item being re-selected. Permanently adding 'UPLOADING' to the query (as I originally proposed) would only matter across runs, but it opens exactly the hazard you're describing: if uploadNow() (which uses ExistingWorkPolicy.REPLACE) is called while a previous run is still actively uploading something, a second run could pick up and re-upload a file the first run hasn't finished with yet.

A better fix is to keep UPLOADING out of the uploadable set, and instead reclaim stale UPLOADING rows once, at the very start of doWork(), before reconciliation. Since normal scheduling uses ExistingWorkPolicy.KEEP (no two runs pending/running at once) and REPLACE cancels the outstanding request, any row still UPLOADING when a new run starts is leftover from a run whose process died mid-upload — never one that's still alive — so it's safe to reset it back to PENDING:

// CrashReportDao.kt
`@Query`("UPDATE crash_reports SET status = 'PENDING' WHERE status = 'UPLOADING'")
fun resetStuckUploads()
// CrashUploadWorker.kt, at the top of doWork(), before CrashReportScanner.reconcile(...)
dao.resetStuckUploads()

This recovers interrupted uploads without ever letting UPLOADING rows be double-selected by a genuinely concurrent run, addressing your concern directly. Let me know if you'd like me to fold this into a plan for the actual code change.


✏️ Learnings added
Learnt from: rayworks
Repo: rayworks/CrashReporter PR: 4
File: crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashReportDao.kt:27-35
Timestamp: 2026-07-15T08:53:01.217Z
Learning: In `crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt`, `CrashUploadWorker.doWork()` calls `dao.getUploadable(maxAttempts)` only once at the top of each run, then marks/finalizes each entity's status synchronously inside the loop before moving to the next — so within a single run, marking an entity `UPLOADING` never causes it to be re-selected. In `crashreporter/src/main/java/com/balsikandar/crashreporter/upload/CrashUploadManager.kt`, uploads are scheduled via `WorkManager.enqueueUniqueWork(UNIQUE_WORK_NAME, policy, request)`; normal scheduling (`maybeEnqueue()`) uses `ExistingWorkPolicy.KEEP`, while `uploadNow()` uses `ExistingWorkPolicy.REPLACE` — the only path that could race with an actively running worker. Preferred fix for crash reports stuck in `UPLOADING` forever (e.g., process killed mid-upload): reset `UPLOADING` rows back to `PENDING` once at the start of `doWork()` (before reconciliation), rather than permanently including `'UPLOADING'` in the `getUploadable`/`countUploadable` queries, since the latter risks double-uploading a file that's still actively being processed by another run.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +45 to +50
for (entity in dao.getUploadable(maxAttempts)) {
val file = File(entity.filePath)
if (!file.exists()) {
dao.deleteByPath(entity.filePath)
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Respect WorkManager cancellation to prevent spurious failures and attempt-count exhaustion.

If the system cancels the worker (e.g., due to suddenly lost network constraints), the thread is interrupted. Ignoring this cancellation causes the worker to loop through all remaining reports, fail them immediately, and increment their attempt counts—falsely penalizing reports for a system-level constraint loss.

  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt#L45-L50: Add if (isStopped) break at the start of the for loop to safely abort remaining items.
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt#L68-L72: Inside the catch block, explicitly check if (e is InterruptedException || isStopped) break so that an interrupted ongoing upload isn't treated as a retryable failure.
📍 Affects 1 file
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt#L45-L50 (this comment)
  • crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt#L68-L72
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt`
around lines 45 - 50, Update CrashUploadWorker’s upload loop to check isStopped
at the start of each iteration and break before processing further reports; in
its catch block, also break when the exception is an InterruptedException or
isStopped is true, preventing cancellation from being recorded as a retryable
upload failure. Apply both changes at
crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt
lines 45-50 and 68-72.

Comment on lines +59 to +62
if (deleteAfterUpload) {
file.delete()
dao.deleteByPath(entity.filePath)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent duplicate uploads if file deletion fails.

If file.delete() returns false due to an I/O glitch, the database row is still unconditionally deleted. On the subsequent run, CrashReportScanner will rediscover the file on disk and re-insert it as a brand-new PENDING report, causing an infinite loop of duplicate uploads.

Only delete the database record if the file was successfully deleted or genuinely no longer exists.

🛠️ Proposed fix
-                    if (deleteAfterUpload) {
-                        file.delete()
-                        dao.deleteByPath(entity.filePath)
-                    }
+                    if (deleteAfterUpload) {
+                        if (file.delete() || !file.exists()) {
+                            dao.deleteByPath(entity.filePath)
+                        } else {
+                            Log.w(TAG, "Failed to delete uploaded report: ${file.name}")
+                        }
+                    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (deleteAfterUpload) {
file.delete()
dao.deleteByPath(entity.filePath)
}
if (deleteAfterUpload) {
if (file.delete() || !file.exists()) {
dao.deleteByPath(entity.filePath)
} else {
Log.w(TAG, "Failed to delete uploaded report: ${file.name}")
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crashreporter/src/main/java/com/balsikandar/crashreporter/upload/internal/CrashUploadWorker.kt`
around lines 59 - 62, Update the deleteAfterUpload cleanup in CrashUploadWorker
so dao.deleteByPath(entity.filePath) runs only when file.delete() succeeds or
the file is already absent; retain the database row when deletion fails while
the file still exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant