Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

**Fixed**

- Fixed an issue where native crashes on API level 30 were silently discarded due to a missing tombstone trace. Skeleton crash reports with basic metadata but no stack traces are now generated in this case.

Comment on lines 30 to +33
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Changelog entry says "Fatal Issue Reports are now being sent on API level 30", but the PR changes appear specific to native crashes where ApplicationExitInfo.getTraceInputStream() is null (skeleton native-crash reports). Consider rewording to avoid implying all fatal issue reports were previously not sent on API 30, and to call out that these are native crash reports without tombstone/stack traces.

Copilot uses AI. Check for mistakes.
- Fixed an issue where 3rd-party dependencies like `androidx` were included in the released Javadoc artifacts. Also updated the style of `capture-timber` and `capture-apollo` javadocs to match.

### iOS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,14 @@ internal class IssueReporter(
val lastReasonResult = latestAppExitInfoProvider.get()
if (lastReasonResult is LatestAppExitReasonResult.Valid) {
val lastReason = lastReasonResult.applicationExitInfo
lastReason.traceInputStream?.use {
latestAppExitInfoProvider.convertExitReasonToFbsReportType(lastReason.reason)?.let { fatalIssueType ->
latestAppExitInfoProvider.convertExitReasonToFbsReportType(lastReason.reason)?.let { fatalIssueType ->
lastReason.traceInputStream.use { traceInputStream ->
issueReporterProcessor?.processAppExitReport(
fatalIssueType = fatalIssueType,
timestamp = lastReason.timestamp,
description = lastReason.description,
traceInputStream = it,
traceInputStream = traceInputStream,
signalNumber = lastReason.status,
)
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

traceInputStream is only wrapped in .use {} after convertExitReasonToFbsReportType(...) succeeds. If the exit reason maps to null but traceInputStream is non-null, the stream will never be closed. To avoid leaking the underlying FD/stream, wrap the .use {} around the stream unconditionally (and then decide inside the block whether to call processAppExitReport).

Copilot uses AI. Check for mistakes.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@ interface IIssueReporterProcessor {
* (e.g. [ReportType.AppNotResponding] or [ReportType.NativeCrash]).
* @param timestamp The timestamp when the issue occurred.
* @param description Optional description of the issue.
* @param traceInputStream Input stream containing the fatal issue trace data.
* @param traceInputStream Input stream containing the fatal issue trace data. May be null for
* native crashes on API level 30 where tombstone data is unavailable; a skeleton report will
* be generated in that case.
* @param signalNumber The signal number from [android.app.ApplicationExitInfo.getStatus] for
* native crashes. Used to populate signal name and description in skeleton reports.
*/
fun processAppExitReport(
fatalIssueType: Byte,
timestamp: Long,
description: String? = null,
traceInputStream: InputStream,
traceInputStream: InputStream? = null,
signalNumber: Int = 0,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,23 @@ internal class IssueReporterProcessor(
* (e.g. [ReportType.AppNotResponding] or [ReportType.NativeCrash])
* @param timestamp The timestamp when the issue occurred
* @param description Optional description of the issue
* @param traceInputStream Input stream containing the fatal issue trace data
* @param traceInputStream Input stream containing the fatal issue trace data. May be null for
* native crashes on API level 30 where tombstone data is unavailable; a skeleton report will
* be generated in that case.
* @param signalNumber The signal number from [android.app.ApplicationExitInfo.getStatus] for
* native crashes. Used to populate signal name and description in skeleton reports.
*/
override fun processAppExitReport(
fatalIssueType: Byte,
timestamp: Long,
description: String?,
traceInputStream: InputStream,
traceInputStream: InputStream?,
signalNumber: Int,
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

signalNumber has no default value in the concrete IssueReporterProcessor.processAppExitReport override, but several call sites (including existing tests in this module) invoke processAppExitReport(...) without providing it. In Kotlin, default parameters from the interface are only applied when calling through the interface type, so these calls will fail to compile when the receiver is IssueReporterProcessor. Consider giving signalNumber a default of 0 in the override as well (or update all call sites to pass an explicit value).

Suggested change
signalNumber: Int,
signalNumber: Int = 0,

Copilot uses AI. Check for mistakes.
) {
runCatching {
if (fatalIssueType == ReportType.AppNotResponding) {
streamingReportsProcessor.processAndPersistANR(
traceInputStream,
traceInputStream!!,
timestamp,
reporterIssueStore.generateFatalIssueFilePath(),
clientAttributes,
Expand All @@ -129,6 +134,7 @@ internal class IssueReporterProcessor(
deviceMetrics,
description,
traceInputStream,
signalNumber,
)
builder.finish(report)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,29 @@ internal object NativeCrashProcessor {
"SIGTRAP" to "Trace/breakpoint trap",
)

private val signalNumberToName =
mapOf(
4 to "SIGILL",
5 to "SIGTRAP",
6 to "SIGABRT",
7 to "SIGBUS",
8 to "SIGFPE",
11 to "SIGSEGV",
)

fun process(
builder: FlatBufferBuilder,
sdk: Int,
appMetrics: Int,
deviceMetrics: Int,
description: String?,
traceInputStream: InputStream,
traceInputStream: InputStream?,
signalNumber: Int = 0,
): Int {
if (traceInputStream == null) {
return createSkeletonReport(builder, sdk, appMetrics, deviceMetrics, description, signalNumber)
}

val tombstone = Tombstone.parseFrom(traceInputStream)
val nativeErrors = mutableListOf<Int>()
val threadOffsets = mutableListOf<Int>()
Expand Down Expand Up @@ -139,6 +154,55 @@ internal object NativeCrashProcessor {
)
}

/**
* Creates a minimal native crash report for cases where tombstone data is unavailable
* (e.g. native crashes on API level 30 where [android.app.ApplicationExitInfo.getTraceInputStream]
* returns null). The report includes the crash description and uses empty stack traces,
* empty thread details, and empty binary images. When a [signalNumber] is available
* (from [android.app.ApplicationExitInfo.getStatus]), the signal name and description
* are used for the error name and reason fields.
*/
private fun createSkeletonReport(
builder: FlatBufferBuilder,
sdk: Int,
appMetrics: Int,
deviceMetrics: Int,
description: String?,
signalNumber: Int = 0,
): Int {
val signalName = signalNumberToName[signalNumber]
val name = builder.createString(signalName ?: description ?: "Native crash")
val reason = builder.createString(
signalName?.let { signalDescriptions[it] } ?: "Native crash",
)
val errorOffset =
Error.createError(
builder,
name,
reason,
Error.createStackTraceVector(builder, intArrayOf()),
ErrorRelation.CausedBy,
)
val threadDetailsOffset =
ThreadDetails.createThreadDetails(
builder,
0.toUShort(),
ThreadDetails.createThreadsVector(builder, intArrayOf()),
)
return Report.createReport(
builder,
sdk,
ReportType.NativeCrash,
appMetrics,
deviceMetrics,
Report.createErrorsVector(builder, intArrayOf(errorOffset)),
threadDetailsOffset,
Report.createBinaryImagesVector(builder, intArrayOf()),
stateOffset = 0,
featureFlagsOffset = 0,
)
}

private fun createErrorOffset(
builder: FlatBufferBuilder,
description: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,60 @@ class IssueReporterProcessorTest {
assertThat(deviceMetrics?.cpuAbis(0)).isEqualTo("armeabi-v7a")
}

@Test
fun processAppExitReport_whenNativeCrashWithNullTrace_shouldCreateSkeletonNativeReport() {
val description = "Segmentation fault"

processor.processAppExitReport(
ReportType.NativeCrash,
FAKE_TIME_STAMP,
description,
traceInputStream = null,
signalNumber = 11, // SIGSEGV
)
Comment on lines +286 to +292
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Because IssueReporterProcessor.processAppExitReport(...) now requires signalNumber (no default on the concrete override), several existing calls in this same test file still invoke it with only 4 arguments (e.g., the ANR and existing native-crash tests). Those call sites will no longer compile unless you either (a) update them to pass signalNumber = 0, or (b) add a default value for signalNumber on the override as well (keeping in mind defaults are resolved by the static type).

Copilot uses AI. Check for mistakes.

verify(issueReporterStorage).persistFatalIssue(
eq(FAKE_TIME_STAMP),
issueReportCaptor.capture(),
reportTypeCaptor.capture(),
)
val buffer = ByteBuffer.wrap(issueReportCaptor.firstValue)
val report = Report.getRootAsReport(buffer)
assertThat(report.errorsLength).isEqualTo(1)
assertThat(reportTypeCaptor.firstValue).isEqualTo(ReportType.NativeCrash)

val capturedError = report.errors(0)!!
assertThat(capturedError.name).isEqualTo("SIGSEGV")
assertThat(capturedError.reason).isEqualTo("Segmentation violation (invalid memory reference)")
assertThat(capturedError.stackTraceLength).isEqualTo(0)
assertThat(report.threadDetails?.threadsLength).isEqualTo(0)
assertThat(report.binaryImagesLength).isEqualTo(0)
}

@Test
fun processAppExitReport_whenNativeCrashWithNullTraceAndUnknownSignal_shouldFallBackToDescription() {
val description = "Segmentation fault"

processor.processAppExitReport(
ReportType.NativeCrash,
FAKE_TIME_STAMP,
description,
traceInputStream = null,
signalNumber = 0,
)

verify(issueReporterStorage).persistFatalIssue(
eq(FAKE_TIME_STAMP),
issueReportCaptor.capture(),
reportTypeCaptor.capture(),
)
val buffer = ByteBuffer.wrap(issueReportCaptor.firstValue)
val report = Report.getRootAsReport(buffer)
val capturedError = report.errors(0)!!
assertThat(capturedError.name).isEqualTo(description)
assertThat(capturedError.reason).isEqualTo("Native crash")
}

@Test
fun processAppExitReport_withInvalidReason_shouldNotInteractWithStorage() {
val description = null
Expand Down