Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ A Jetpack Compose library that displays real-time debug information as an overla
| Create new extension | Implement `LogSource` or `NetworkRequestSource`, self-register in `init` block |
| Modify bug report flow | `BugReportGenerator.kt` (orchestration), `BugReportDraftStorage.kt` (persistence) |
| Change metrics display | `internal/ui/DebugOverlayPanel.kt` (compact overlay), `DebugOverlayPanelDataSource.kt` (aggregation) |
| Modify crash persistence | `internal/crash/CrashHandler.kt` (chains the uncaught-exception handler), `CrashRecordBuilder.kt` (assembles the record), `CrashRecordStorage.kt` (persistence), `CrashLogTabContent.kt`/`CrashLogDetailScreen.kt` (UI). Data is gathered in `DebugOverlayDataRepository.writeCrashRecordSync()` |
Comment thread
Manabu-GT marked this conversation as resolved.

## Build Commands

Expand Down
10 changes: 7 additions & 3 deletions debugoverlay-core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,23 @@
android:excludeFromRecents="true" />

<!--
FileProvider for sharing bug reports via Intent.
FileProvider for sharing exports via Intent (bug report archives, crash logs).
Required since Android 7.0 (API 24) - file:// URIs throw FileUriExposedException
when shared with other apps. FileProvider creates content:// URIs that grant
temporary read access to the receiving app.

One provider serves every export directory: <paths> below declares them all, and
grants are per-URI, so a second authority would add a ContentProvider to app startup
without narrowing what any recipient can read.
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.debugoverlay.bugreport.provider"
android:authorities="${applicationId}.debugoverlay.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/debugoverlay_bugreport_paths" />
android:resource="@xml/debugoverlay_file_provider_paths" />
</provider>
</application>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.ms.square.debugoverlay.internal.OverlayViewManager
import com.ms.square.debugoverlay.internal.bugreport.BugReportGenerator
import com.ms.square.debugoverlay.internal.bugreport.IntentShareExporter
import com.ms.square.debugoverlay.internal.bugreport.validateFilename
import com.ms.square.debugoverlay.internal.crash.CrashHandler
import com.ms.square.debugoverlay.internal.data.DebugOverlayDataRepository
import com.ms.square.debugoverlay.internal.ui.DebugPanelActivity
import com.ms.square.debugoverlay.internal.util.checkMainThread
Expand Down Expand Up @@ -102,9 +103,28 @@ public object DebugOverlay {
repository = repository,
activityProvider = viewManager
)

installCrashHandler(repository)
}
}

/**
* Chains a [CrashHandler] in front of whatever [Thread.UncaughtExceptionHandler] was
* previously installed (the platform default, or another crash reporter like
* Crashlytics), so persisting a crash record never interferes with existing crash
* handling. Captured exactly once here.
*/
private fun installCrashHandler(repository: DebugOverlayDataRepository) {
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()

Thread.setDefaultUncaughtExceptionHandler(
CrashHandler(
previousHandler = previousHandler,
captureCrash = repository::writeCrashRecordSync
)
)
}

// CopyOnWriteArrayList enables lock-free iteration during bug report generation
// synchronized block in addBugReportContributor ensures atomic duplicate detection
internal val bugReportContributors = CopyOnWriteArrayList<BugReportDataContributor>()
Expand Down Expand Up @@ -227,9 +247,10 @@ public object DebugOverlay {
public var bugReportExporter: BugReportExporter = initial.bugReportExporter

/**
* Maximum number of entries kept in the built-in Logcat tab buffer.
* Also passed to `logcat -T N` / `-t N` so it controls how many lines the
* OS replays on producer start (panel open) and on bug-report snapshot.
* Maximum number of entries kept in the built-in Logcat tab buffer, which is also what
* bug reports and crash records read.
*
* Reassigning this resizes the buffer immediately.
*
* Default is [Config.DEFAULT_MAX_LOGCAT_ENTRIES] (300). Each entry holds a parsed
* [com.ms.square.debugoverlay.model.LogEntry].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.ms.square.debugoverlay.internal.bugreport.model.BugReportState
import com.ms.square.debugoverlay.internal.bugreport.model.DraftInfo
import com.ms.square.debugoverlay.internal.bugreport.model.UserInput
import com.ms.square.debugoverlay.internal.util.checkFolderExists
import com.ms.square.debugoverlay.internal.util.isDirectChildOf
import com.ms.square.debugoverlay.internal.util.runCatchingNonCancellation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -257,7 +258,7 @@ internal class DefaultBugReportDraftStorage(
override suspend fun deleteFolder(folder: File): Unit = withContext(Dispatchers.IO) {
val deleted = folderMutex.withLock {
// Safety check: only delete folders that are direct children of our drafts directory
if (!isDirectChildOfDraftsDir(folder)) {
if (!folder.isDirectChildOf(draftsDir)) {
Logger.w("Refusing to delete folder outside drafts directory: ${folder.absolutePath}")
return@withLock false
}
Expand All @@ -281,20 +282,6 @@ internal class DefaultBugReportDraftStorage(
}
}

/**
* Checks if the given folder is a direct child of [draftsDir].
*
* Uses canonical paths to resolve symlinks and ".." traversal attacks.
*/
private fun isDirectChildOfDraftsDir(folder: File): Boolean = runCatching {
val canonicalFolder = folder.canonicalFile
val canonicalDraftsDir = draftsDir.canonicalFile
canonicalFolder.parentFile == canonicalDraftsDir
}.getOrElse { e ->
Logger.w("Failed to resolve canonical path for safety check: ${e.javaClass.simpleName} - ${e.message}")
false
}

// ========== Draft Management ==========

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,18 @@ package com.ms.square.debugoverlay.internal.bugreport
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.ms.square.debugoverlay.BugReportExporter
import com.ms.square.debugoverlay.core.R
import com.ms.square.debugoverlay.formatBugReportMarkdown
import com.ms.square.debugoverlay.internal.Logger
import com.ms.square.debugoverlay.internal.bugreport.model.BugReportArchiveImpl
import com.ms.square.debugoverlay.internal.util.debugOverlayFileUri
import com.ms.square.debugoverlay.internal.util.runCatchingNonCancellation
import com.ms.square.debugoverlay.model.BugReport
import com.ms.square.debugoverlay.model.ExportResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

private const val PROVIDER_AUTHORITY_SUFFIX = ".debugoverlay.bugreport.provider"

/**
* Default exporter that shares the bug report via Android's share sheet.
* Uses Intent.ACTION_SEND with FileProvider for secure file sharing.
Expand All @@ -30,8 +28,7 @@ internal object IntentShareExporter : BugReportExporter {
UnsupportedOperationException("IntentShareExporter requires file-backed archive")
)
}
val authority = "${context.packageName}$PROVIDER_AUTHORITY_SUFFIX"
val uri = FileProvider.getUriForFile(context, authority, file)
val uri = context.debugOverlayFileUri(file)

val subject = context.getString(R.string.debugoverlay_bug_report_subject, file.nameWithoutExtension)
val chooserTitle = context.getString(R.string.debugoverlay_share_bug_report)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import com.ms.square.debugoverlay.internal.data.model.DeviceInfo
import com.ms.square.debugoverlay.internal.data.model.JankStatsUiState
import com.ms.square.debugoverlay.model.LogEntry
import com.ms.square.debugoverlay.model.NetworkRequest
import kotlinx.serialization.Serializable

/**
* Bundled custom log source data.
* Ensures logs and source name are always provided together.
*/
@Serializable
internal data class CustomLogSourceData(val logs: List<LogEntry>, val sourceName: String)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
import com.ms.square.debugoverlay.DebugOverlay
import com.ms.square.debugoverlay.core.R
import com.ms.square.debugoverlay.internal.bugreport.BugReportGenerator
import com.ms.square.debugoverlay.internal.ui.CountBadge
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -155,8 +156,8 @@ internal fun BugReporterFab(

// Badge positioned at top-right corner of FAB
if (draftCount > 0 && fabState != BugReporterFabState.Processing) {
DraftCountBadge(
draftCount = draftCount,
CountBadge(
count = draftCount,
modifier = Modifier.align(Alignment.TopEnd)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.ms.square.debugoverlay.internal.crash

import com.ms.square.debugoverlay.internal.Logger

/**
* Captures a crash record on an uncaught exception, then always delegates to
* [previousHandler] so the app's normal crash behavior (and any other installed crash
* reporter, e.g. Crashlytics) is unaffected.
*
* Installed once by [com.ms.square.debugoverlay.DebugOverlay.install]. [captureCrash] must
* be non-suspending and should only read in-memory data to complete quickly before the process dies.
*
* @param previousHandler The handler that was installed before this one, captured once
* at install time. Never re-fetched, so a handler installed by another SDK *after*
* DebugOverlay is never clobbered.
* @param captureCrash Builds and persists the crash record for the given thread/throwable.
*/
internal class CrashHandler(
private val previousHandler: Thread.UncaughtExceptionHandler?,
private val captureCrash: (Thread, Throwable) -> Unit,
) : Thread.UncaughtExceptionHandler {

@Suppress("TooGenericExceptionCaught")
override fun uncaughtException(thread: Thread, throwable: Throwable) {
try {
captureCrash(thread, throwable)
} catch (t: Throwable) {
// Deliberately broad: capture failure must never prevent the delegate call below
// from running, since that's what keeps other crash reporters (e.g. Crashlytics)
// and the platform's own crash handling working.
runCatching { Logger.e("CrashHandler failed to capture crash record", t) }
} finally {
// previousHandler is effectively always non-null on real devices — the platform
// installs its own default handler before any app code runs, well before
// DebugOverlay.install() (see class doc). If it's ever null, the crash record above
// is already persisted; there's nothing more this library needs to do.
previousHandler?.uncaughtException(thread, throwable)
Comment thread
Manabu-GT marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.ms.square.debugoverlay.internal.crash

import com.ms.square.debugoverlay.internal.bugreport.model.AppInfo
import com.ms.square.debugoverlay.internal.bugreport.model.CustomLogSourceData
import com.ms.square.debugoverlay.model.LogEntry
import com.ms.square.debugoverlay.model.NetworkRequest
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import java.io.File
import java.util.UUID

/**
* A crash captured by [CrashHandler] and persisted to disk so it survives process death.
*
* @param version Schema version, for forward-compatible reads via `ignoreUnknownKeys`. Carries
* [EncodeDefault] because it always equals its default, which the serializer would otherwise
* omit — leaving stored records with no version to read.
* @param id unique identifier for the crash record
* @param timestampMs When the exception was caught (epoch millis).
* @param threadName Name of the thread that crashed.
* @param exceptionType Fully-qualified exception class name.
* @param message The exception's message, if any.
* @param stackTrace Full stack trace text, including any "Caused by" chain.
* @param appInfo App info captured.
* @param logcatLogs Recent logcat entries leading up to the crash.
* @param customLogSourceData Recent custom log source entries, null if none registered.
* @param networkRequests Recent network requests leading up to the crash.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
internal data class CrashRecord(
@EncodeDefault
val version: Int = 1,
@EncodeDefault
val id: String = UUID.randomUUID().toString(),
Comment thread
Manabu-GT marked this conversation as resolved.
val timestampMs: Long,
val threadName: String,
val exceptionType: String,
val message: String?,
val stackTrace: String,
val appInfo: AppInfo?,
val logcatLogs: List<LogEntry>,
val customLogSourceData: CustomLogSourceData?,
val networkRequests: List<NetworkRequestSummary>,
)

/**
* The subset of a [NetworkRequest] a crash record keeps.
*
* Deliberately not [NetworkRequest] itself: that carries request/response bodies (up to 2MB
* each by default in the OkHttp extension) and [com.ms.square.debugoverlay.model.NetworkError]
* repeats the response body in its `stackTrace`. Persisting those could mean serializing 2+
* megabytes on the crashing thread — most likely to fail exactly when the crash is an
* OutOfMemoryError — to store data neither the detail screen nor the text export ever renders.
* These are the only fields both consumers read.
*/
@Serializable
internal data class NetworkRequestSummary(
val timestampMs: Long,
val method: String,
val url: String,
val statusCode: Int?,
val durationMs: Long,
val errorTitle: String? = null,
val errorMessage: String? = null,
)

/** Keeps only the fields a crash record renders — see [NetworkRequestSummary]. */
internal fun NetworkRequest.toSummary() = NetworkRequestSummary(
timestampMs = timestampMs,
method = method,
url = url,
statusCode = statusCode,
durationMs = durationMs,
errorTitle = error?.title,
errorMessage = error?.message
)

/** A [CrashRecord] paired with the file it was loaded from. */
internal data class CrashRecordInfo(val filePath: String, val record: CrashRecord) {
val file: File get() = File(filePath)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.ms.square.debugoverlay.internal.crash

import com.ms.square.debugoverlay.internal.bugreport.model.AppInfo
import com.ms.square.debugoverlay.internal.bugreport.model.CustomLogSourceData
import com.ms.square.debugoverlay.model.LogEntry
import com.ms.square.debugoverlay.model.NetworkRequest

internal const val DEFAULT_MAX_LOG_LINES = 100

/**
* Assembles a [CrashRecord] from already-captured, in-memory data.
*
* Pure and non-suspending: every input is passed in, so this can run on the crashing thread
* and can be tested without a [android.content.Context]. Each log/request source is trimmed
* to the last [maxLogLines] entries, and network requests are reduced to
* [NetworkRequestSummary] so bodies never reach disk.
*/
internal fun buildCrashRecord(
thread: Thread,
throwable: Throwable,
appInfo: AppInfo?,
logcatLogs: List<LogEntry>,
customLogSourceData: CustomLogSourceData?,
networkRequests: List<NetworkRequest>,
maxLogLines: Int = DEFAULT_MAX_LOG_LINES,
): CrashRecord {
val maxLines = maxLogLines.coerceAtLeast(0)
return CrashRecord(
timestampMs = System.currentTimeMillis(),
threadName = thread.name,
exceptionType = throwable.javaClass.name,
message = throwable.message,
stackTrace = throwable.stackTraceToString(),
appInfo = appInfo,
logcatLogs = logcatLogs.takeLast(maxLines),
customLogSourceData = customLogSourceData?.let { it.copy(logs = it.logs.takeLast(maxLines)) },
networkRequests = networkRequests.takeLast(maxLines).map { it.toSummary() }
)
}
Loading
Loading