Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cd150e7
feat(android): report JS exceptions as ReactNativeWebException, kept …
krassx Sep 30, 2026
0b7c404
test(e2e): JS exceptions in Android bundles, handled, unhandled and f…
krassx Sep 30, 2026
56deb41
test(e2e): a release fatal is one ReactNativeWebException bundle
krassx Sep 30, 2026
4b75281
test(e2e): register debug IDs before a render throw
krassx Sep 30, 2026
1705c19
feat(ios): report JS exceptions as ReactNativeWebException
krassx Sep 30, 2026
3275093
test(ios): count concurrent settle with an atomic so a double-settle …
krassx Sep 30, 2026
5667fa5
test(e2e): JS exceptions in iOS bundles, on the iPhone
krassx Oct 1, 2026
fb9449b
test(e2e): pin iOS unhandled recovery as failing on beta3
krassx Oct 1, 2026
c437703
feat(report): upload with severity and labels, and showReportDialog
krassx Oct 1, 2026
cd79431
feat(report): createReport, a report the app fills and uploads, bridg…
krassx Oct 1, 2026
6179794
feat(android): createReport through a one-slot registry
krassx Oct 1, 2026
0542501
fix(android): ignore a created-report callback after its reservation …
krassx Oct 1, 2026
0dbeee8
test(report): pin severity bounds and the exact argument errors
krassx Oct 1, 2026
6a9535e
test(ios): keep the settle-once race to the two callers
krassx Oct 1, 2026
4d61e8a
test(report): cover created-report attachment and native rejection paths
krassx Oct 1, 2026
edc94cc
test(ios): keep each settle-once window on its own release
krassx Oct 1, 2026
02ddf11
Merge main into this branch
krassx Oct 1, 2026
5eb1935
fix(android): drop the created-report stubs the main merge left besid…
krassx Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;

/**
* The Android half of the `Bugsee` TurboModule.
*
* <p>Thin on purpose. Everything that translates between the JS wire shape and
* the SDK's types lives in {@link BugseeTokens}, {@link BugseeStatusMapper},
* {@link ReportOps} and {@link ReportHandlerBridge}, which are plain Java and
* {@link ReportOps}, {@link ReportHandlerBridge} and {@link CreatedReports}, which are plain Java and
* unit-tested without React Native or a device; what is left here is the part
* that can only be exercised by running the app, and is covered by the example
* app's e2e instead.
Expand Down Expand Up @@ -67,6 +68,7 @@ public void logUnhandledException(
private static final String E_REPORT_HANDLE_DEAD = "E_REPORT_HANDLE_DEAD";
private static final String E_REPORT_ATTACHMENT_REJECTED = "E_REPORT_ATTACHMENT_REJECTED";
private static final String E_REPORT_BAD_ARGUMENT = "E_REPORT_BAD_ARGUMENT";
private static final String E_REPORT_CREATE_BUSY = "E_REPORT_CREATE_BUSY";
// The stable code of src/attributes/errors.ts's AttributeErrorCode.Rejected.
private static final String E_ATTRIBUTE_REJECTED = "E_ATTRIBUTE_REJECTED";

Expand Down Expand Up @@ -118,6 +120,9 @@ public void invalidate() {
// next runtime cannot know them, so the reports must not wait out
// their deadlines.
ReportHandlerBridge.shared().detach(this);
// The next runtime cannot know a created-report handle. Drop it and
// free the one slot before that runtime reserves its own.
CreatedReports.shared().clear();
Comment thread
krassx marked this conversation as resolved.
// Same for any outstanding vh request, and disables the view tree:
// the next runtime's anchor has not mounted yet.
DataRequestBridge.shared().detach(dataRequestSink);
Expand Down Expand Up @@ -763,29 +768,99 @@ public void reportAddDataAttachment(
}
}

// Task 8.2b. No registry yet, so this does not call the SDK: the SDK made none.
/**
* One created report at a time. The slot is reserved before the SDK is
* asked, because {@code onCreated} arrives later, on the main thread.
* A throw from that call ends the reservation; otherwise the listener does,
* with {@code cr-<n>} or null.
*/
@Override
public void createReport(final Promise promise) {
Comment thread
cursor[bot] marked this conversation as resolved.
promise.resolve(null);
final CreatedReports registry = CreatedReports.shared();
final int stamp = registry.tryReserve();
if (stamp == 0) {
Log.i(TAG, "created report - busy");
promise.reject(
E_REPORT_CREATE_BUSY,
"A created report is already outstanding.");
return;
}
try {
Bugsee.createReport(created -> deliverCreatedReport(registry, stamp, created, promise));
} catch (final Throwable thrown) {
// The SDK threw before the listener ran. The stamp no-ops once
// this reservation has been cleared and another one opened.
registry.fulfil(stamp, null);
promise.reject(thrown);
}
}

/**
* Runs on the SDK's listener thread. A throw here would escape onto that
* thread, so it is caught, the reservation is ended, and the promise rejects.
* The stamp is the one captured at reserve time: a listener that arrives
* after {@code invalidate} cleared that reservation cannot end a newer one.
*/
private static void deliverCreatedReport(
final CreatedReports registry,
final int stamp,
@Nullable final Report created,
final Promise promise
) {
String handle = null;
try {
handle = registry.fulfil(stamp, created);
if (handle == null) {
Log.i(TAG, "created report - none");
} else {
Log.i(TAG, "created report " + handle + " created");
}
promise.resolve(handle);
} catch (final Throwable thrown) {
if (handle != null) {
registry.take(handle);
} else {
registry.fulfil(stamp, null);
}
promise.reject(thrown);
}
}

// Task 8.2b. No created-report handle exists until the registry lands.
@Override
public void createdReportRead(final String handleId, final Promise promise) {
rejectHandleDead(promise);
final Report report = CreatedReports.shared().get(handleId);
if (report == null) {
rejectHandleDead(promise);
return;
}
try {
promise.resolve(snapshotToWritableMap(ReportOps.read(report)));
} catch (final Throwable e) {
promise.reject(e);
}
}

// Task 8.2b. No created-report handle exists until the registry lands.
@Override
public void createdReportUpdate(
final String handleId,
final String patchJson,
final Promise promise
) {
rejectHandleDead(promise);
final Report report = CreatedReports.shared().get(handleId);
if (report == null) {
rejectHandleDead(promise);
return;
}
try {
ReportOps.applyJson(report, patchJson);
promise.resolve(null);
} catch (final ReportOps.BadArgument e) {
promise.reject(E_REPORT_BAD_ARGUMENT, e.getMessage());
} catch (final Throwable e) {
promise.reject(e);
}
}

// Task 8.2b. No created-report handle exists until the registry lands.
@Override
public void createdReportAddDataAttachment(
final String handleId,
Expand All @@ -794,10 +869,27 @@ public void createdReportAddDataAttachment(
@Nullable final String mimeType,
final Promise promise
) {
rejectHandleDead(promise);
final Report report = CreatedReports.shared().get(handleId);
if (report == null) {
rejectHandleDead(promise);
return;
}
final byte[] data;
try {
data = Base64.decode(base64, Base64.NO_WRAP);
} catch (final IllegalArgumentException e) {
promise.reject(E_REPORT_BAD_ARGUMENT, "data must be base64-encoded");
return;
}
try {
settleCreatedAttachment(
handleId, ReportOps.addData(report, data, name, mimeType), promise);
} catch (final Throwable e) {
promise.reject(e);
}
}

// Task 8.2b. No created-report handle exists until the registry lands. No move argument.
/** Copied, not moved: a created report has no {@code move} argument. */
@Override
public void createdReportAddFileAttachment(
final String handleId,
Expand All @@ -806,13 +898,44 @@ public void createdReportAddFileAttachment(
@Nullable final String mimeType,
final Promise promise
) {
rejectHandleDead(promise);
final Report report = CreatedReports.shared().get(handleId);
if (report == null) {
rejectHandleDead(promise);
return;
}
try {
settleCreatedAttachment(
handleId,
ReportOps.addFile(report, path, name, mimeType, false),
promise);
} catch (final Throwable e) {
promise.reject(e);
}
}

// Task 8.2b. No created-report handle exists until the registry lands.
/**
* Takes the report first, so the slot is free whether or not the upload
* succeeds. The handle is dead afterwards.
*/
@Override
public void createdReportUpload(final String handleId, final Promise promise) {
rejectHandleDead(promise);
final Report report = CreatedReports.shared().take(handleId);
if (report == null) {
rejectHandleDead(promise);
return;
}
try {
Bugsee.upload(report, ok -> {
try {
Log.i(TAG, "created report " + handleId + " uploaded ok=" + ok);
promise.resolve(ok);
} catch (final Throwable thrown) {
promise.reject(thrown);
}
});
} catch (final Throwable thrown) {
promise.reject(thrown);
}
}

/**
Expand All @@ -834,6 +957,21 @@ private static void settleAttachment(
}
}

/** Same decision as {@link #settleAttachment}, against the created-report registry. */
private static void settleCreatedAttachment(
final String handleId,
final boolean added,
final Promise promise
) {
if (added) {
promise.resolve(null);
} else if (CreatedReports.shared().get(handleId) == null) {
rejectHandleDead(promise);
} else {
promise.reject(E_REPORT_ATTACHMENT_REJECTED, "The SDK declined the attachment");
}
}

private static void rejectHandleDead(final Promise promise) {
promise.reject(
E_REPORT_HANDLE_DEAD,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.bugsee.reactnative;

import androidx.annotation.Nullable;

import com.bugsee.library.contracts.reporting.Report;

/**
* The one created report this process may have outstanding.
*
* <p>A slot is taken by {@link #reserve()} or {@link #tryReserve()} and held
* until {@link #fulfil} stores a report, {@link #fulfil} is given null,
* {@link #take} removes the report, or {@link #clear} drops whatever is
* there. A second reserve while the slot is taken returns false. Handles are
* {@code cr-<n>}, numbered from 1 for the life of the process, and are not
* reused after a take or a clear.
*
* <p>{@link #clear()} does not cancel an SDK listener that is already in
* flight. Each reservation carries a stamp, and {@link #fulfil} ignores any
* stamp that is not the current one, so that listener cannot store its report
* into a reservation opened afterwards or free that reservation.
*/
final class CreatedReports {

private static final CreatedReports SHARED = new CreatedReports();

/** True from a successful reserve until that reservation is ended. */
private boolean creating;

/**
* Stamp of the open reservation. {@code 0} is never a live stamp: it is
* what {@link #tryReserve()} returns when busy, and what {@link #clear()}
* and a finished {@link #fulfil} leave behind so every earlier stamp misses.
*/
private int currentStamp;

/** Next stamp to issue. Skips {@code 0}, which means "not admitted". */
private int nextStamp = 1;

@Nullable
private Report report;

@Nullable
private String handle;

/** Next suffix. Never reset: a handle is fresh for the process. */
private int next = 1;

static CreatedReports shared() {
return SHARED;
}

/** Package-private so a test can build an instance that is not {@link #shared()}. */
CreatedReports() {
}

/** False while a created report is outstanding or being created. */
synchronized boolean reserve() {
return tryReserve() != 0;
}

/**
* {@code 0} when a report is outstanding or being created. Otherwise the
* stamp {@link #fulfil} must present to end this reservation.
*/
synchronized int tryReserve() {
if (creating || report != null) {
return 0;
}
creating = true;
currentStamp = nextStamp;
nextStamp += 1;
if (nextStamp == 0) {
nextStamp = 1;
}
return currentStamp;
}

/**
* Ends the reservation {@code stamp} names. A report gets {@code cr-<n>}
* and holds the slot. Null frees the slot and mints nothing. A stamp that
* is not the current one, including every stamp issued before
* {@link #clear()}, changes nothing and returns null.
*/
@Nullable
synchronized String fulfil(final int stamp, @Nullable final Report created) {
if (!creating || stamp == 0 || stamp != currentStamp) {
return null;
}
creating = false;
currentStamp = 0;
if (created == null) {
return null;
}
handle = "cr-" + next;
next += 1;
report = created;
return handle;
}

@Nullable
synchronized Report get(final String handleId) {
if (report != null && handleId != null && handleId.equals(handle)) {
return report;
}
return null;
}

/** Removes the report and frees the slot. An unknown handle removes nothing. */
@Nullable
synchronized Report take(final String handleId) {
final Report created = get(handleId);
if (created == null) {
return null;
}
report = null;
handle = null;
return created;
}

/** Drops the slot and makes every stamp issued so far fail {@link #fulfil}. */
synchronized void clear() {
creating = false;
report = null;
handle = null;
currentStamp = 0;
}
}
Loading
Loading