Compare commits

...
Author SHA1 Message Date
Bailey Dixon 9dcb280eff feat(android): unify local reliability reporting 2026-08-04 21:32:18 -04:00
Bailey Dixon a90067292a Merge pull request #300 from Codename-11/fix/android-chat-render-identity
fix(android): stabilize chat identity and issue labeling
2026-08-04 21:26:06 -04:00
27 changed files with 1455 additions and 283 deletions
+13
View File
@@ -191,6 +191,19 @@ jobs:
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
# The Play AAB carries its mapping for Play Console deobfuscation, but
# sideload issue reports need the exact mapping from this immutable build.
# Keep both variants as a workflow artifact (not a public release asset).
- name: Retain R8 mappings for retrace
uses: actions/upload-artifact@v7
with:
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
path: |
app/build/outputs/mapping/googlePlayRelease/mapping.txt
app/build/outputs/mapping/sideloadRelease/mapping.txt
if-no-files-found: error
retention-days: 90
- name: Scan release DEX for unsupported collection APIs
run: |
python3 scripts/check-android-collection-apis.py \
+5
View File
@@ -6,10 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
### Fixed
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Android 1.6.1] - 2026-08-03
+20
View File
@@ -15,6 +15,26 @@ sequences across restore, replay, deltas, thinking, and usage updates. Voice's
temporary transcript row now occupies an auxiliary key namespace disjoint from
real message rows.
## 2026-08-04 — Android reliability and support foundation
Android fatal capture and centrally classified handled failures now converge on
a versioned, allowlisted reliability record. Reports are redacted before local
persistence, capped at 20 records with 14-day retention, written atomically,
and correlated only with random app/report identifiers. Expected cancellation
and permission denial remain non-reportable. The pre-existing one-file crash
format migrates locally on first launch.
Crash recovery leads with the recovery outcome and no-upload guarantee, then
requires an explicit review before copy, share, or GitHub actions. Diagnostics
adds an offline support-information review using the same exact redacted text.
Android issue prefills now request the Android area while repository-wide issue
ownership remains maintainer-reviewed, and the release workflow retains both
variant R8 mappings for deterministic retrace.
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
hashed product correlation, and OOM emergency writing until their lifecycle,
privacy, and false-positive behavior can be validated on devices.
## 2026-08-02 — Android Russian localization
Android now ships complete Russian catalogs for the main and sideload builds.
+8
View File
@@ -494,6 +494,14 @@ the new app version and a higher `appVersionCode`.
in `app/build.gradle.kts`. Never rename the sideload APK — the
in-app update checker matches assets by `.apk` + `sideload` in the
name, and user-docs verify steps cite the filename.
The release workflow also retains
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
in-app or sideload report, download the artifact for the exact version/SHA and
run Android's retrace tool with the matching flavor mapping:
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.ReliabilityRedactor
enum class DiagnosticCategory(val label: String) {
Api("API"),
@@ -124,6 +126,7 @@ object DiagnosticsLog {
endpointRole: String? = null,
url: String? = null,
elapsedMs: Long? = null,
reliabilityContext: String? = null,
) {
record(
category = category,
@@ -135,6 +138,17 @@ object DiagnosticsLog {
elapsedMs = elapsedMs,
stacktrace = throwable?.let { stackTraceText(it) },
)
if (throwable != null) {
runCatching {
ReliabilityCenter.recordHandled(
title = title,
detail = detail ?: throwable.message,
throwable = throwable,
context = reliabilityContext,
routeRole = endpointRole,
)
}
}
}
private fun stackTraceText(t: Throwable): String =
@@ -167,10 +181,8 @@ object DiagnosticsLog {
val prefix = noQuery.substring(0, schemeEnd + 3)
val rest = noQuery.substring(schemeEnd + 3)
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
val authority = rest.substring(0, slash)
val path = rest.substring(slash)
val safeAuthority = authority.substringAfterLast('@')
prefix + safeAuthority + path
prefix + "[host]" + path
} else {
noQuery
}
@@ -197,7 +209,7 @@ object DiagnosticsLog {
*/
private fun redactTrace(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
val redacted = redact(trimmed)
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
return if (redacted.length > MAX_TRACE_LENGTH) {
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
} else {
@@ -205,8 +217,5 @@ object DiagnosticsLog {
}
}
private fun redact(value: String): String =
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
}
@@ -0,0 +1,123 @@
package com.hermesandroid.relay.reliability
import android.content.Context
import android.os.Build
import com.hermesandroid.relay.BuildConfig
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.util.concurrent.Executors
/**
* Android boundary for the local reliability store. Nothing in this object has
* a network path; writes stay in app-private storage until a user explicitly
* reviews and shares text through the UI.
*/
object ReliabilityCenter {
private val writer = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "hermes-reliability-writer").apply { isDaemon = true }
}
private val appSessionId = ReliabilityReport.newId("app")
@Volatile
private var store: ReliabilityStore? = null
fun initialize(context: Context) {
if (store != null) return
synchronized(this) {
if (store == null) {
store = ReliabilityStore(
java.io.File(context.applicationContext.filesDir, "reliability/reports-v1.json"),
)
}
}
}
fun recordFatal(
context: Context,
throwable: Throwable,
threadName: String,
timeIso: String = Instant.now().toString(),
): ReliabilityReport {
initialize(context)
val summary = buildString {
append(throwable.javaClass.simpleName.ifBlank { "Unexpected crash" })
throwable.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
}
val report = ReliabilityReport(
reportId = ReliabilityReport.newId(),
appSessionId = appSessionId,
timeIso = timeIso,
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = summary,
recovery = "The app restarted. Work already running on Hermes may still be active.",
reportRecommended = true,
technicalDetail = "Thread: $threadName\n${stackTraceText(throwable)}",
environment = environment(),
pendingReview = true,
)
// Fatal capture must complete before the platform terminates the process.
store?.append(report)
return report
}
fun recordHandled(
title: String,
detail: String?,
throwable: Throwable,
context: String?,
routeRole: String? = null,
) {
val target = store ?: return
val classification = ReliabilityClassifier.classify(throwable, context)
if (!classification.shouldPersist) return
val report = ReliabilityReport(
reportId = ReliabilityReport.newId(),
appSessionId = appSessionId,
timeIso = Instant.now().toString(),
kind = classification.kind,
owner = classification.owner,
severity = ReliabilitySeverity.Error,
summary = title,
recovery = detail ?: "The failure was handled; retry or review Diagnostics if it continues.",
reportRecommended = classification.reportRecommended,
technicalDetail = stackTraceText(throwable),
routeRole = routeRole,
environment = environment(),
)
writer.execute { runCatching { target.append(report) } }
}
fun reports(context: Context): List<ReliabilityReport> {
initialize(context)
return store?.readAll().orEmpty()
}
fun pendingCrash(context: Context): ReliabilityReport? =
reports(context).lastOrNull { it.kind == ReliabilityKind.FatalCrash && it.pendingReview }
fun markReviewed(context: Context, reportId: String) {
initialize(context)
store?.markReviewed(reportId)
}
fun import(context: Context, report: ReliabilityReport) {
initialize(context)
store?.append(report)
}
fun environment(): ReliabilityEnvironment = ReliabilityEnvironment(
versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
flavor = BuildConfig.FLAVOR,
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
model = Build.MODEL.orEmpty().ifBlank { "?" },
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
sdkInt = Build.VERSION.SDK_INT,
)
private fun stackTraceText(throwable: Throwable): String =
StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
}
@@ -0,0 +1,361 @@
package com.hermesandroid.relay.reliability
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CancellationException
const val RELIABILITY_SCHEMA_VERSION = 1
@Serializable
enum class ReliabilityKind {
FatalCrash,
AnrSignal,
RecoverableProductError,
Connectivity,
Authentication,
RateLimit,
ServiceUnavailable,
ExpectedCancellation,
UserDenial,
}
@Serializable
enum class ReliabilityOwner(val label: String) {
Android("Android"),
Dashboard("Dashboard"),
Api("API"),
Relay("Relay"),
UpstreamGateway("Upstream Gateway"),
Voice("Voice"),
Unknown("Unknown"),
}
@Serializable
enum class ReliabilitySeverity { Info, Warning, Error, Fatal }
@Serializable
data class ReliabilityEnvironment(
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
)
/**
* Allowlisted local reliability record. There are deliberately no fields for
* prompts, messages, profile names, product session IDs, URLs, media, or paths.
*/
@Serializable
data class ReliabilityReport(
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
val reportId: String,
val appSessionId: String,
val timeIso: String,
val kind: ReliabilityKind,
val owner: ReliabilityOwner,
val severity: ReliabilitySeverity,
val summary: String,
val recovery: String,
val reportRecommended: Boolean,
val technicalDetail: String? = null,
val routeRole: String? = null,
val environment: ReliabilityEnvironment,
val pendingReview: Boolean = false,
) {
fun shortTitle(): String = summary.lineSequence().firstOrNull().orEmpty().ifBlank {
kind.name
}.take(90)
fun versionLine(): String =
"${environment.versionName} (code ${environment.versionCode}) ${environment.flavor}"
fun environmentBlock(): String = buildString {
appendLine("- Hermes-Relay version/tag: ${environment.versionName} (code ${environment.versionCode})")
appendLine(
"- Install surface: " +
if (environment.flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
)
appendLine(
"- Android device and OS: ${environment.manufacturer} ${environment.model} — " +
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
)
append("- Connection mode: ${routeRole ?: "unknown"}")
}
/** Exact local review/copy/share payload. Redaction is repeated for legacy defense in depth. */
fun toPlainText(): String = ReliabilityRedactor.redact(
buildString {
appendLine("Hermes-Relay support information")
appendLine("Report: $reportId")
appendLine("Session: $appSessionId")
appendLine("Time: $timeIso")
appendLine("Type: ${kind.name}")
appendLine("Owner: ${owner.label}")
appendLine("App: ${versionLine()}")
appendLine(
"Device: ${environment.manufacturer} ${environment.model} — " +
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
)
routeRole?.let { appendLine("Route: $it") }
appendLine()
appendLine("What happened: $summary")
appendLine("Recovery: $recovery")
technicalDetail?.let {
appendLine()
appendLine("Technical detail (redacted)")
append(it)
}
},
)
companion object {
fun newId(prefix: String = "rpt"): String =
"$prefix-${UUID.randomUUID().toString().replace("-", "").take(16)}"
}
}
/** Old `files/crash/last-crash.json` shape, retained only for one-way migration. */
@Serializable
data class LegacyCrashSnapshot(
val timeIso: String,
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
val threadName: String,
val exceptionSummary: String,
val stackTrace: String,
)
fun migrateLegacyCrash(
old: LegacyCrashSnapshot,
reportId: String = ReliabilityReport.newId(),
appSessionId: String = ReliabilityReport.newId("legacy"),
): ReliabilityReport = ReliabilityReport(
reportId = reportId,
appSessionId = appSessionId,
timeIso = runCatching { Instant.parse(old.timeIso).toString() }.getOrDefault(old.timeIso),
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = ReliabilityRedactor.redact(old.exceptionSummary, 240),
recovery = "The app restarted. Work already running on Hermes may still be active.",
reportRecommended = true,
technicalDetail = ReliabilityRedactor.redact("Thread: ${old.threadName}\n${old.stackTrace}"),
environment = ReliabilityEnvironment(
old.versionName, old.versionCode, old.flavor, old.manufacturer, old.model,
old.androidRelease, old.sdkInt,
),
pendingReview = true,
)
data class ReliabilityClassification(
val kind: ReliabilityKind,
val owner: ReliabilityOwner,
val reportRecommended: Boolean,
val shouldPersist: Boolean,
)
object ReliabilityClassifier {
fun classify(throwable: Throwable, context: String? = null): ReliabilityClassification {
val message = throwable.message.orEmpty().lowercase()
val owner = ownerForContext(context)
return when {
throwable is CancellationException -> ReliabilityClassification(
ReliabilityKind.ExpectedCancellation, owner, reportRecommended = false, shouldPersist = false,
)
throwable is SecurityException && ("denied" in message || "permission" in message) ->
ReliabilityClassification(
ReliabilityKind.UserDenial, ReliabilityOwner.Android,
reportRecommended = false, shouldPersist = false,
)
"429" in message || "rate limit" in message || "too many requests" in message ->
ReliabilityClassification(
ReliabilityKind.RateLimit, owner, reportRecommended = false, shouldPersist = true,
)
"401" in message || "403" in message || "unauthorized" in message || "forbidden" in message ->
ReliabilityClassification(
ReliabilityKind.Authentication, owner, reportRecommended = false, shouldPersist = true,
)
throwable is java.net.UnknownHostException ||
throwable is java.net.ConnectException ||
throwable is java.net.SocketTimeoutException ||
"timeout" in message -> ReliabilityClassification(
ReliabilityKind.Connectivity, owner, reportRecommended = false, shouldPersist = true,
)
"503" in message || "service unavailable" in message || "gateway_draining" in message ->
ReliabilityClassification(
ReliabilityKind.ServiceUnavailable, owner,
reportRecommended = false, shouldPersist = true,
)
else -> ReliabilityClassification(
ReliabilityKind.RecoverableProductError, owner,
reportRecommended = true, shouldPersist = true,
)
}
}
fun ownerForContext(context: String?): ReliabilityOwner = when (context?.lowercase()) {
"dashboard", "manage", "dashboard_auth" -> ReliabilityOwner.Dashboard
"gateway", "gateway_chat", "upstream_gateway" -> ReliabilityOwner.UpstreamGateway
"transcribe", "synthesize", "voice_config", "record", "voice" -> ReliabilityOwner.Voice
"pair", "save_and_test", "media_fetch", "relay" -> ReliabilityOwner.Relay
"send_message", "load_sessions", "create_session", "api" -> ReliabilityOwner.Api
"android", "permission", "ui" -> ReliabilityOwner.Android
else -> ReliabilityOwner.Unknown
}
}
/** Local, deterministic redaction. It runs before persistence and again before export. */
object ReliabilityRedactor {
const val MAX_TECHNICAL_LENGTH = 8_000
private const val HIDDEN = "[hidden]"
private val secretAssignment = Regex(
"""(?i)\b(authorization|bearer|cookie|set-cookie|token|api[_-]?key|session[_-]?token|pairing[_-]?code|password|secret|oauth[_-]?code)\s*[:=]\s*((?:Bearer\s+)?[^\s,;]+)""",
)
private val sensitiveHeader = Regex("""(?im)^\s*(authorization|cookie|set-cookie)\s*:\s*.+$""")
private val standaloneBearer = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""")
private val sensitivePayload = Regex(
"""(?i)\b(prompt|message|content|transcript|reasoning|tool[_-]?(args|result)|profile[_-]?name)\s*[:=]\s*([^\r\n]+)""",
)
private val url = Regex("""(?i)\b(?:https?|wss?)://[^\s)\]}>,]+""")
private val ipv4 = Regex("""(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?![\w.])""")
private val uuid = Regex("""(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b""")
private val email = Regex("""(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""")
private val namedHost = Regex("""(?i)\b(host|hostname)\s*[:=]\s*[^\s,;]+""")
private val unresolvedHost = Regex("""(?i)(?:resolve|resolved|host)\s+[\"']([^\"']+)[\"']""")
private val windowsPath = Regex("""(?i)\b[A-Z]:\\(?:[^\s\\]+\\)+[^\s]+""")
private val unixPrivatePath = Regex("""(?i)(?:/home/|/Users/|/data/user/\d+/|/sdcard/)[^\s]+""")
fun redact(value: String, maxLength: Int = MAX_TECHNICAL_LENGTH): String {
var result = value
result = sensitiveHeader.replace(result) { "${it.groupValues[1]}: $HIDDEN" }
result = secretAssignment.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = standaloneBearer.replace(result, "Bearer $HIDDEN")
result = sensitivePayload.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = url.replace(result, "[url hidden]")
result = ipv4.replace(result, "[host hidden]")
result = uuid.replace(result, "[id hidden]")
result = email.replace(result, "[email hidden]")
result = namedHost.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = unresolvedHost.replace(result, "host \"$HIDDEN\"")
result = windowsPath.replace(result, "[path hidden]")
result = unixPrivatePath.replace(result, "[path hidden]")
return if (result.length > maxLength) {
result.take(maxLength) + "\n… (truncated)"
} else {
result
}
}
}
@Serializable
private data class ReliabilityEnvelope(
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
val reports: List<ReliabilityReport> = emptyList(),
)
/** Pure file store so retention, bounds, and migration behavior are JVM-testable. */
class ReliabilityStore(
private val file: File,
private val maxReports: Int = 20,
private val retentionDays: Long = 14,
) {
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
private val lock = Any()
fun readAll(now: Instant = Instant.now()): List<ReliabilityReport> = synchronized(lock) {
val decoded = decode()
val retained = prune(decoded, now)
if (retained != decoded) write(retained)
retained
}
fun append(report: ReliabilityReport, now: Instant = Instant.now()) = synchronized(lock) {
write(prune(decode() + sanitize(report), now))
}
fun markReviewed(reportId: String, now: Instant = Instant.now()) = synchronized(lock) {
write(
prune(
decode().map { if (it.reportId == reportId) it.copy(pendingReview = false) else it },
now,
),
)
}
private fun sanitize(report: ReliabilityReport): ReliabilityReport = report.copy(
summary = ReliabilityRedactor.redact(report.summary, 240),
recovery = ReliabilityRedactor.redact(report.recovery, 240),
technicalDetail = report.technicalDetail?.let(ReliabilityRedactor::redact),
routeRole = report.routeRole?.let { ReliabilityRedactor.redact(it, 40) },
)
private fun prune(reports: List<ReliabilityReport>, now: Instant): List<ReliabilityReport> {
val cutoff = now.minusSeconds(retentionDays * 24 * 60 * 60)
return reports
.distinctBy { it.reportId }
.filter { report -> runCatching { Instant.parse(report.timeIso) >= cutoff }.getOrDefault(true) }
.sortedBy { it.timeIso }
.takeLast(maxReports.coerceAtLeast(1))
}
private fun decode(): List<ReliabilityReport> = runCatching {
if (!file.isFile) return emptyList()
json.decodeFromString<ReliabilityEnvelope>(file.readText()).reports
}.getOrDefault(emptyList())
private fun write(reports: List<ReliabilityReport>) {
file.parentFile?.mkdirs()
val temp = File(file.parentFile, "${file.name}.tmp")
temp.writeText(json.encodeToString(ReliabilityEnvelope(reports = reports)))
runCatching {
java.nio.file.Files.move(
temp.toPath(),
file.toPath(),
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
}.recoverCatching {
java.nio.file.Files.move(
temp.toPath(),
file.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
}.getOrThrow()
}
}
object SupportBundleBuilder {
const val MAX_REPORTS = 10
fun build(reports: List<ReliabilityReport>): String {
val selected = reports.sortedByDescending { it.timeIso }.take(MAX_REPORTS)
return ReliabilityRedactor.redact(
buildString {
appendLine("Hermes-Relay support bundle")
appendLine("Local-only export · review before sharing")
appendLine("Reports: ${selected.size}")
selected.forEachIndexed { index, report ->
appendLine()
appendLine("===== Report ${index + 1} =====")
append(report.toPlainText())
appendLine()
}
},
maxLength = 64_000,
)
}
}
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.CrashReport
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.util.CrashReporter
import com.hermesandroid.relay.util.IssueReport
import kotlinx.coroutines.Dispatchers
@@ -57,16 +57,16 @@ import kotlinx.coroutines.withContext
* crashed). Render it inside the app theme so the dialog picks up Material
* colors — see RelayApp.
*
* Peeks the report on first composition (does NOT delete on read) and clears it
* only when the user acknowledges it (Dismiss/Report). A report the user merely
* Peeks the report on first composition (does not mark it reviewed on read) and
* acknowledges it only on Dismiss/Report. A report the user merely
* glanced at — or never reached because the app was backgrounded — therefore
* survives relaunches instead of being lost after one view; once acknowledged
* it's deleted and won't reappear.
* survives relaunches instead of being lost after one view. Once acknowledged,
* it remains in bounded Diagnostics history but does not interrupt startup again.
*/
@Composable
fun CrashReportGate() {
val context = LocalContext.current
var report by remember { mutableStateOf<CrashReport?>(null) }
var report by remember { mutableStateOf<ReliabilityReport?>(null) }
var checked by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -79,19 +79,26 @@ fun CrashReportGate() {
CrashReportDialog(
report = pending,
onDismiss = {
// Acknowledged (Dismiss/Report) → delete so it won't reappear.
// Acknowledged (Dismiss/Report) → retain as reviewed history without showing again.
// Copy does NOT route through here, so the report stays available
// across relaunches until the user actually dismisses or reports it.
CrashReporter.clearPending(context)
CrashReporter.clearPending(context, pending.reportId)
report = null
},
)
}
@Composable
private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) {
val context = LocalContext.current
val reportText = remember(report) { report.toPlainText() }
var showDetails by remember(report.reportId) { mutableStateOf(false) }
val copiedMessage = stringResource(R.string.crash_toast_copied)
val noShareMessage = stringResource(R.string.crash_toast_no_share)
val shareTitle = stringResource(R.string.crash_share_title)
val reportSubject = stringResource(R.string.crash_share_subject, report.shortTitle())
val openedMessage = stringResource(R.string.crash_toast_opened)
val noBrowserMessage = stringResource(R.string.crash_toast_no_browser)
Dialog(
onDismissRequest = onDismiss,
@@ -126,25 +133,34 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
@@ -157,45 +173,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Crash report copied")
},
) { Text(stringResource(R.string.common_copy)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
"Hermes-Relay crash report — ${report.shortTitle()}",
reportText,
chooserTitle = "Share crash report",
)
if (!shared) {
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Report copied — no app found to share to")
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
// Copy the FULL report first; the URL only carries the
// head of the trace, so the user can paste the rest.
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(
context,
if (opened) "Full report copied — paste into the issue if it's truncated"
else "Report copied — no browser found to open GitHub",
)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.SupportBundleBuilder
import com.hermesandroid.relay.util.IssueReport
data class SupportReviewState(
val reportCount: Int,
val text: String,
val shareEnabled: Boolean,
)
internal fun buildSupportReviewState(reports: List<ReliabilityReport>): SupportReviewState =
SupportReviewState(
reportCount = reports.takeLast(SupportBundleBuilder.MAX_REPORTS).size,
text = SupportBundleBuilder.build(reports),
shareEnabled = reports.isNotEmpty(),
)
/** Exact review surface for the local text handed to clipboard/share. */
@Composable
fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
val context = LocalContext.current
val copied = stringResource(R.string.support_bundle_copied)
val noShare = stringResource(R.string.support_bundle_no_share)
val chooser = stringResource(R.string.support_bundle_share_title)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = RoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.reportCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
RoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
}
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
}
},
) { Text(stringResource(R.string.common_share)) }
}
}
}
}
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -42,6 +45,13 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
import com.hermesandroid.relay.ui.components.SupportBundleDialog
import com.hermesandroid.relay.ui.components.SupportReviewState
import com.hermesandroid.relay.ui.components.buildSupportReviewState
import com.hermesandroid.relay.reliability.ReliabilityCenter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
@@ -118,6 +128,8 @@ fun DiagnosticsScreen(
// Tapping a check backed by a concrete log entry opens its full detail.
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
var supportReview by remember { mutableStateOf<SupportReviewState?>(null) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
@@ -232,6 +244,19 @@ fun DiagnosticsScreen(
},
)
OutlinedButton(
onClick = {
scope.launch {
supportReview = withContext(Dispatchers.IO) {
buildSupportReviewState(ReliabilityCenter.reports(context))
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.support_bundle_review))
}
Text(
text = stringResource(R.string.diag_recent_diagnostics),
style = MaterialTheme.typography.titleMedium,
@@ -256,6 +281,9 @@ fun DiagnosticsScreen(
selectedEntry?.let { entry ->
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
}
supportReview?.let { state ->
SupportBundleDialog(state = state, onDismiss = { supportReview = null })
}
}
// -----------------------------------------------------------------------------
@@ -1,81 +1,43 @@
package com.hermesandroid.relay.util
import android.content.Context
import android.os.Build
import android.os.Process
import android.util.Log
import com.hermesandroid.relay.BuildConfig
import kotlinx.serialization.Serializable
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.LegacyCrashSnapshot
import com.hermesandroid.relay.reliability.ReliabilityRedactor
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.migrateLegacyCrash
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlin.system.exitProcess
/**
* Lightweight, privacy-respecting crash capture — no Firebase/Crashlytics, no
* network, no third-party SDK.
*
* On a fatal uncaught exception we persist a structured report to the app's
* private storage, then **re-raise to the platform's previous handler** so the
* system "app stopped" dialog still shows and Google Play Android vitals still
* records the crash. We only observe; we never swallow.
*
* On the next launch [CrashReportGate] reads the pending report and offers the
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
* full report, **share** it via the system sheet (email / chat / notes — the
* path for users without a GitHub account and for sideload installs Play vitals
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
* turns a one-star "it keeps crashing" review into an actionable issue with a
* stack trace attached; share/copy cover everyone else. Every outbound path is
* user-initiated — nothing is transmitted automatically.
*/
/** Local-only fatal capture. The platform handler still owns termination and Play vitals. */
object CrashReporter {
private const val TAG = "CrashReporter"
private const val DIR = "crash"
private const val FILE = "last-crash.json"
/**
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
* truncate very long URLs, so we ship the head of the trace in the form and
* copy the *full* report to the clipboard for the user to paste if needed.
*/
private const val MAX_TRACE_FOR_URL = 3000
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
prettyPrint = false
}
private const val LEGACY_DIR = "crash"
private const val LEGACY_FILE = "last-crash.json"
private const val MAX_TRACE_FOR_URL = 3_000
private val json = Json { ignoreUnknownKeys = true }
@Volatile
private var installed = false
/**
* Install the process-wide uncaught-exception handler. Idempotent; call as
* early as possible in [com.hermesandroid.relay.HermesRelayApp.onCreate] so
* crashes during the rest of app init are captured too.
*/
fun install(context: Context) {
ReliabilityCenter.initialize(context)
migrateLegacy(context)
if (installed) return
installed = true
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
persist(appContext, thread, throwable)
} catch (t: Throwable) {
// Never let the reporter itself worsen the crash.
Log.e(TAG, "Failed to persist crash report", t)
ReliabilityCenter.recordFatal(appContext, throwable, thread.name.orEmpty().ifBlank { "?" })
} catch (captureFailure: Throwable) {
// Never let the reporter worsen or replace the original crash.
Log.e(TAG, "Failed to persist local crash report", captureFailure)
}
// Re-raise so the platform behaves exactly as it would without us:
// system dialog + Play vitals collection.
if (previous != null) {
previous.uncaughtException(thread, throwable)
} else {
@@ -85,155 +47,70 @@ object CrashReporter {
}
}
private fun persist(context: Context, thread: Thread, throwable: Throwable) {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val report = CrashReport(
timeIso = isoNow(),
versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
flavor = BuildConfig.FLAVOR,
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
model = Build.MODEL.orEmpty().ifBlank { "?" },
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
sdkInt = Build.VERSION.SDK_INT,
threadName = thread.name.orEmpty().ifBlank { "?" },
exceptionSummary = "${throwable.javaClass.name}: ${throwable.message.orEmpty()}".trim(),
stackTrace = trace,
)
val dir = File(context.filesDir, DIR).apply { mkdirs() }
File(dir, FILE).writeText(json.encodeToString(report))
fun peekPending(context: Context): ReliabilityReport? {
migrateLegacy(context)
return ReliabilityCenter.pendingCrash(context)
}
/** Read the pending report without clearing it. */
fun peekPending(context: Context): CrashReport? = readFile(context)
/** Read the pending report and delete it (show-once semantics). */
fun consumePending(context: Context): CrashReport? {
val report = readFile(context)
clearPending(context)
return report
fun clearPending(context: Context, reportId: String? = null) {
val target = reportId ?: peekPending(context)?.reportId ?: return
ReliabilityCenter.markReviewed(context, target)
}
fun clearPending(context: Context) {
runCatching { reportFile(context).delete() }
}
private fun readFile(context: Context): CrashReport? = runCatching {
val file = reportFile(context)
if (!file.exists()) return null
json.decodeFromString<CrashReport>(file.readText())
}.getOrNull()
private fun reportFile(context: Context): File =
File(File(context.filesDir, DIR), FILE)
/**
* Build a pre-filled GitHub "new issue" URL.
*
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is
* a GitHub public-preview feature and was observed to silently not apply
* (only `title` carried), which is unacceptable for a crash reporter that
* fires on devices we can't retry from. `blank_issues_enabled: true` in
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
* structure is preserved without depending on the preview path.
*/
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
title = "[Bug]: Crash — ${report.shortTitle()}",
fun buildGithubIssueUrl(report: ReliabilityReport): String = IssueReport.buildGithubIssueUrl(
title = "[Bug]: Android crash — ${ReliabilityRedactor.redact(report.shortTitle(), 90)}",
bodyMarkdown = buildIssueBody(report),
labels = "bug",
labels = "bug,area:android",
)
private fun buildIssueBody(report: CrashReport): String {
val trace = report.stackTrace.let {
private fun buildIssueBody(report: ReliabilityReport): String {
val trace = report.technicalDetail.orEmpty().let {
if (it.length > MAX_TRACE_FOR_URL) {
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full report copied to your clipboard)"
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — review/copy the local report for the remainder)"
} else {
it
}
}
return buildString {
appendLine(
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
"or personal data from the trace below.",
)
appendLine()
appendLine("### Affected area")
appendLine("Android app")
appendLine()
appendLine("### What happened?")
appendLine("The app closed unexpectedly. Auto-captured crash report below.")
appendLine()
appendLine("### Environment")
appendLine(report.environmentBlock())
appendLine()
appendLine("### Crash")
appendLine("```")
appendLine(trace)
appendLine("```")
appendLine()
append("<sub>Captured by the Hermes-Relay in-app crash reporter · ${report.timeIso}</sub>")
return ReliabilityRedactor.redact(
buildString {
appendLine("> No report was uploaded automatically. This is the locally reviewed, redacted copy.")
appendLine()
appendLine("### Affected area")
appendLine("Android app")
appendLine()
appendLine("### What happened?")
appendLine(report.summary)
appendLine()
appendLine("### Recovery")
appendLine(report.recovery)
appendLine()
appendLine("### Environment")
appendLine(report.environmentBlock())
appendLine("- Report ID: ${report.reportId}")
appendLine("- App session ID: ${report.appSessionId}")
appendLine()
appendLine("### Redacted technical detail")
appendLine("```")
appendLine(trace)
appendLine("```")
appendLine()
append("<sub>Captured locally by Hermes-Relay · ${report.timeIso}</sub>")
},
maxLength = 12_000,
)
}
/** Import the pre-v1 one-file crash format once, redacting before the new store sees it. */
private fun migrateLegacy(context: Context) {
val file = File(File(context.filesDir, LEGACY_DIR), LEGACY_FILE)
if (!file.isFile) return
runCatching {
val old = json.decodeFromString<LegacyCrashSnapshot>(file.readText())
val report = migrateLegacyCrash(old)
ReliabilityCenter.import(context, report)
file.delete()
}.onFailure {
Log.w(TAG, "Legacy crash report could not be migrated", it)
}
}
private fun isoNow(): String = runCatching {
OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).toString()
}.getOrDefault(java.util.Date().toString())
}
/**
* Structured, serializable crash snapshot. Persisted as JSON between the
* crashing session and the next launch.
*/
@Serializable
data class CrashReport(
val timeIso: String,
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
val threadName: String,
val exceptionSummary: String,
val stackTrace: String,
) {
fun deviceLine(): String = "$manufacturer $model"
fun androidLine(): String = "Android $androidRelease (SDK $sdkInt)"
fun versionLine(): String = "$versionName (code $versionCode) $flavor"
/** A short, human title for the GitHub issue — class name + trimmed message. */
fun shortTitle(): String {
val firstLine = exceptionSummary.lineSequence().firstOrNull().orEmpty()
val simpleClass = firstLine.substringBefore(':').substringAfterLast('.').ifBlank { "crash" }
val message = firstLine.substringAfter(':', "").trim()
return (if (message.isBlank()) simpleClass else "$simpleClass: $message").take(90)
}
/** Full, copy-paste-ready report shown in the dialog and copied to clipboard. */
fun toPlainText(): String = buildString {
appendLine("Hermes-Relay crash report")
appendLine("Time: $timeIso")
appendLine("App: ${versionLine()}")
appendLine("Device: ${deviceLine()}")
appendLine("Android: ${androidLine()}")
appendLine("Thread: $threadName")
appendLine()
append(stackTrace)
}
/** Matches the `environment` textarea default in `bug_report.yml`. */
fun environmentBlock(): String = buildString {
appendLine("- Hermes-Relay version/tag: $versionName (code $versionCode)")
appendLine(
"- Install surface: " +
if (flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
)
appendLine("- Android device and OS: ${deviceLine()} — ${androidLine()}")
append("- Connection mode: LAN / Tailscale / public TLS / other")
}
}
@@ -36,12 +36,13 @@ object DiagnosticIssuePrefill {
}
/**
* `bug` for Error entries; `question` (an existing repo label) for
* Info/Warning so routine diagnostics don't pollute the bug queue.
* `bug,area:android` for Error entries; `question,area:android` for
* Info/Warning so routine diagnostics don't pollute the bug queue and all
* in-app reports reach the owning Android surface.
*/
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
DiagnosticSeverity.Error -> "bug"
else -> "question"
DiagnosticSeverity.Error -> "bug,area:android"
else -> "question,area:android"
}
/**
@@ -76,7 +77,7 @@ object DiagnosticIssuePrefill {
val whatHappened = DiagnosticsLog.redactReportText(expectation)
?.takeIf { it.isNotBlank() }
?: DEFAULT_WHAT_HAPPENED
return buildString {
val body = buildString {
appendLine(
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
"or personal data from the detail below.",
@@ -111,5 +112,6 @@ object DiagnosticIssuePrefill {
appendLine()
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
}
return DiagnosticsLog.redactReportText(body).orEmpty()
}
}
@@ -231,6 +231,7 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
title = human.title,
detail = human.body,
throwable = t,
reliabilityContext = context,
)
}
}
+14 -1
View File
@@ -2518,7 +2518,14 @@
<string name="attachment_type_file">Arquivo</string>
<!-- CrashReportDialog -->
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
<string name="crash_body">O Hermes-Relay reiniciou após um problema inesperado. Você pode continuar usando o app.</string>
<string name="crash_privacy">Nada foi enviado. Revise o relatório editado localmente antes de compartilhar.</string>
<string name="crash_review">Revisar relatório</string>
<string name="crash_share_title">Compartilhar relatório de falha</string>
<string name="crash_share_subject">Relatório de falha do Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Relatório copiado — nenhum app encontrado para compartilhar</string>
<string name="crash_toast_opened">Relatório completo copiado — revise o problema no GitHub antes de enviar</string>
<string name="crash_toast_no_browser">Relatório copiado — nenhum navegador encontrado para abrir o GitHub</string>
<string name="crash_dismiss">Dispensar</string>
<string name="crash_copy">Copiar</string>
<string name="crash_share">Compartilhar</string>
@@ -3679,4 +3686,10 @@
<string name="plugins_keep">Manter</string>
<string name="plugins_remove">Remover</string>
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
<string name="support_bundle_review">Revisar informações de suporte</string>
<string name="support_bundle_title">Informações de suporte</string>
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
<string name="support_bundle_copied">Informações de suporte copiadas</string>
<string name="support_bundle_no_share">Informações de suporte copiadas — nenhum app encontrado para compartilhar</string>
<string name="support_bundle_share_title">Compartilhar informações de suporte do Hermes-Relay</string>
</resources>
+14 -1
View File
@@ -2636,7 +2636,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay 意外关闭</string>
<string name="crash_body">上次会话崩溃了。发送此报告有助于更快修复问题。</string>
<string name="crash_body">Hermes-Relay 在意外问题后已重新启动。你可以继续使用应用。</string>
<string name="crash_privacy">未发送任何内容。分享前请查看在本地脱敏的报告。</string>
<string name="crash_review">查看报告</string>
<string name="crash_share_title">分享崩溃报告</string>
<string name="crash_share_subject">Hermes-Relay 崩溃报告 — %1$s</string>
<string name="crash_toast_no_share">报告已复制 — 未找到可分享的应用</string>
<string name="crash_toast_opened">完整报告已复制 — 提交前请检查 GitHub 问题</string>
<string name="crash_toast_no_browser">报告已复制 — 未找到可打开 GitHub 的浏览器</string>
<string name="crash_dismiss">忽略</string>
<string name="crash_copy">复制</string>
<string name="crash_share">分享</string>
@@ -3767,4 +3774,10 @@
<string name="plugins_keep">保留</string>
<string name="plugins_remove">移除</string>
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
<string name="support_bundle_review">查看支持信息</string>
<string name="support_bundle_title">支持信息</string>
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
<string name="support_bundle_copied">支持信息已复制</string>
<string name="support_bundle_no_share">支持信息已复制 — 未找到可分享的应用</string>
<string name="support_bundle_share_title">分享 Hermes-Relay 支持信息</string>
</resources>
+14 -1
View File
@@ -2640,7 +2640,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
<string name="crash_body">Die letzte Sitzung ist abgestürzt. Dieser Bericht hilft, den Fehler schneller zu beheben.</string>
<string name="crash_body">Hermes-Relay wurde nach einem unerwarteten Problem neu gestartet. Sie können die App weiter verwenden.</string>
<string name="crash_privacy">Es wurde nichts gesendet. Prüfen Sie den lokal bereinigten Bericht vor dem Teilen.</string>
<string name="crash_review">Bericht prüfen</string>
<string name="crash_share_title">Absturzbericht teilen</string>
<string name="crash_share_subject">Hermes-Relay-Absturzbericht — %1$s</string>
<string name="crash_toast_no_share">Bericht kopiert — keine App zum Teilen gefunden</string>
<string name="crash_toast_opened">Vollständiger Bericht kopiert — GitHub-Problem vor dem Senden prüfen</string>
<string name="crash_toast_no_browser">Bericht kopiert — kein Browser für GitHub gefunden</string>
<string name="crash_dismiss">Schließen</string>
<string name="crash_copy">Kopieren</string>
<string name="crash_share">Teilen</string>
@@ -3839,4 +3846,10 @@
<string name="plugins_keep">Behalten</string>
<string name="plugins_remove">Entfernen</string>
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
<string name="support_bundle_review">Supportinformationen prüfen</string>
<string name="support_bundle_title">Supportinformationen</string>
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
<string name="support_bundle_copied">Supportinformationen kopiert</string>
<string name="support_bundle_no_share">Supportinformationen kopiert — keine App zum Teilen gefunden</string>
<string name="support_bundle_share_title">Hermes-Relay-Supportinformationen teilen</string>
</resources>
+14 -1
View File
@@ -2393,7 +2393,14 @@
<string name="attachment_type_text">Texto</string>
<string name="attachment_type_file">Archivo</string>
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
<string name="crash_body">Hermes-Relay se reinició tras un problema inesperado. Puedes seguir usando la aplicación.</string>
<string name="crash_privacy">No se envió nada. Revisa el informe redactado localmente antes de compartirlo.</string>
<string name="crash_review">Revisar informe</string>
<string name="crash_share_title">Compartir informe de fallo</string>
<string name="crash_share_subject">Informe de fallo de Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Informe copiado — no se encontró una aplicación para compartir</string>
<string name="crash_toast_opened">Informe completo copiado — revisa la incidencia de GitHub antes de enviarla</string>
<string name="crash_toast_no_browser">Informe copiado — no se encontró un navegador para GitHub</string>
<string name="crash_dismiss">Descartar</string>
<string name="crash_copy">Copiar</string>
<string name="crash_share">Compartir</string>
@@ -3524,4 +3531,10 @@
<string name="plugins_keep">Conservar</string>
<string name="plugins_remove">Eliminar</string>
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
<string name="support_bundle_review">Revisar información de soporte</string>
<string name="support_bundle_title">Información de soporte</string>
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
<string name="support_bundle_copied">Información de soporte copiada</string>
<string name="support_bundle_no_share">Información de soporte copiada — no se encontró una aplicación para compartir</string>
<string name="support_bundle_share_title">Compartir información de soporte de Hermes-Relay</string>
</resources>
+14 -1
View File
@@ -2650,7 +2650,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
<string name="crash_body">最後のセッションがクラッシュしました。このレポートを送信すると、問題をより早く修正できます。</string>
<string name="crash_body">予期しない問題の後、Hermes-Relay が再起動しました。アプリは引き続き使用できます。</string>
<string name="crash_privacy">何も送信されていません。共有する前に端末内で編集されたレポートを確認してください。</string>
<string name="crash_review">レポートを確認</string>
<string name="crash_share_title">クラッシュレポートを共有</string>
<string name="crash_share_subject">Hermes-Relay クラッシュレポート — %1$s</string>
<string name="crash_toast_no_share">レポートをコピーしました — 共有できるアプリがありません</string>
<string name="crash_toast_opened">完全なレポートをコピーしました — 送信前に GitHub の内容を確認してください</string>
<string name="crash_toast_no_browser">レポートをコピーしました — GitHub を開けるブラウザがありません</string>
<string name="crash_dismiss">却下する</string>
<string name="crash_copy">コピー</string>
<string name="crash_share">共有</string>
@@ -3838,4 +3845,10 @@
<string name="plugins_keep">保持</string>
<string name="plugins_remove">削除</string>
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
<string name="support_bundle_review">サポート情報を確認</string>
<string name="support_bundle_title">サポート情報</string>
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
<string name="support_bundle_copied">サポート情報をコピーしました</string>
<string name="support_bundle_no_share">サポート情報をコピーしました — 共有できるアプリがありません</string>
<string name="support_bundle_share_title">Hermes-Relay サポート情報を共有</string>
</resources>
+14 -1
View File
@@ -2600,7 +2600,14 @@
<string name="attachment_type_text">Текст</string>
<string name="attachment_type_file">Файл</string>
<string name="crash_title">Hermes-Relay неожиданно закрылся</string>
<string name="crash_body">Последняя сессия завершилась аварийно. Отправка этого отчета поможет быстрее исправить проблему.</string>
<string name="crash_body">Hermes-Relay перезапустился после непредвиденной проблемы. Приложением можно продолжать пользоваться.</string>
<string name="crash_privacy">Ничего не отправлено. Перед отправкой проверьте локально обезличенный отчёт.</string>
<string name="crash_review">Проверить отчёт</string>
<string name="crash_share_title">Поделиться отчётом о сбое</string>
<string name="crash_share_subject">Отчёт о сбое Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Отчёт скопирован — приложение для отправки не найдено</string>
<string name="crash_toast_opened">Полный отчёт скопирован — проверьте проблему GitHub перед отправкой</string>
<string name="crash_toast_no_browser">Отчёт скопирован — браузер для GitHub не найден</string>
<string name="crash_dismiss">Закрыть</string>
<string name="crash_copy">Скопировать</string>
<string name="crash_share">Поделиться</string>
@@ -3560,4 +3567,10 @@
<string name="plugins_keep">Оставить</string>
<string name="plugins_remove">Удалить</string>
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
<string name="support_bundle_review">Проверить сведения для поддержки</string>
<string name="support_bundle_title">Сведения для поддержки</string>
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
<string name="support_bundle_copied">Сведения для поддержки скопированы</string>
<string name="support_bundle_no_share">Сведения для поддержки скопированы — приложение для отправки не найдено</string>
<string name="support_bundle_share_title">Поделиться сведениями поддержки Hermes-Relay</string>
</resources>
+14 -1
View File
@@ -2772,7 +2772,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
<string name="crash_body">The last session crashed. Sending this report helps get it fixed faster.</string>
<string name="crash_body">Hermes-Relay restarted after an unexpected problem. You can continue using the app.</string>
<string name="crash_privacy">Nothing was sent. Review the locally redacted report before choosing copy, share, or GitHub.</string>
<string name="crash_review">Review report</string>
<string name="crash_share_title">Share crash report</string>
<string name="crash_share_subject">Hermes-Relay crash report — %1$s</string>
<string name="crash_toast_no_share">Report copied — no app found to share to</string>
<string name="crash_toast_opened">Full report copied — review the GitHub issue before submitting</string>
<string name="crash_toast_no_browser">Report copied — no browser found to open GitHub</string>
<string name="crash_dismiss">Dismiss</string>
<string name="crash_copy">Copy</string>
<string name="crash_share">Share</string>
@@ -3845,4 +3852,10 @@
<string name="plugins_keep">Keep</string>
<string name="plugins_remove">Remove</string>
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
<string name="support_bundle_review">Review support information</string>
<string name="support_bundle_title">Support information</string>
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
<string name="support_bundle_copied">Support information copied</string>
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
</resources>
@@ -8,9 +8,9 @@ import org.junit.Test
class DiagnosticsLogTest {
@Test
fun sanitizeUrlDropsSecretsAndKeepsRoute() {
fun sanitizeUrlDropsSecretsAndHostWhileKeepingPath() {
assertEquals(
"https://relay.example.test:8767/health",
"https://[host]/health",
DiagnosticsLog.sanitizeUrl(
"https://user:secret@relay.example.test:8767/health?token=abc#frag",
),
@@ -0,0 +1,171 @@
package com.hermesandroid.relay.reliability
import java.io.File
import java.net.SocketTimeoutException
import java.time.Instant
import java.util.concurrent.CancellationException
import java.net.URLDecoder
import com.hermesandroid.relay.util.CrashReporter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReliabilityReportTest {
private val environment = ReliabilityEnvironment(
versionName = "1.6.1",
versionCode = 38,
flavor = "sideload",
manufacturer = "Example",
model = "Phone",
androidRelease = "16",
sdkInt = 36,
)
@Test
fun redactorRemovesSecretsHostsIdentifiersPathsAndContent() {
val raw = """
authorization: Bearer-secret
token=abc123
https://private.example.test:8767/path?q=secret
host 192.168.1.4:8642
session 123e4567-e89b-12d3-a456-426614174000
C:\Users\person\private\file.txt
prompt=private words from a conversation
""".trimIndent()
val redacted = ReliabilityRedactor.redact(raw)
listOf(
"Bearer-secret", "abc123", "private.example.test", "192.168.1.4",
"123e4567-e89b-12d3-a456-426614174000", "person", "private words",
).forEach { assertFalse("leaked $it", redacted.contains(it)) }
assertTrue(redacted.contains("[url hidden]"))
assertTrue(redacted.contains("prompt=[hidden]"))
}
@Test
fun classifierSeparatesCancellationConnectivityAuthRateLimitAndProductErrors() {
assertEquals(
ReliabilityKind.ExpectedCancellation,
ReliabilityClassifier.classify(CancellationException(), "gateway").kind,
)
assertFalse(ReliabilityClassifier.classify(CancellationException(), "gateway").shouldPersist)
assertEquals(
ReliabilityKind.Connectivity,
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").kind,
)
assertEquals(
ReliabilityOwner.UpstreamGateway,
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").owner,
)
assertEquals(
ReliabilityKind.Authentication,
ReliabilityClassifier.classify(IllegalStateException("HTTP 401"), "dashboard").kind,
)
assertEquals(
ReliabilityKind.RateLimit,
ReliabilityClassifier.classify(IllegalStateException("HTTP 429"), "api").kind,
)
assertTrue(
ReliabilityClassifier.classify(IllegalArgumentException("duplicate key"), "ui")
.reportRecommended,
)
}
@Test
fun storeEnforcesAgeCountRedactionAndReviewedState() {
val dir = kotlin.io.path.createTempDirectory("reliability-store").toFile()
val store = ReliabilityStore(File(dir, "reports.json"), maxReports = 3, retentionDays = 14)
val now = Instant.parse("2026-08-04T12:00:00Z")
store.append(report("old", "2026-07-01T00:00:00Z"), now)
store.append(report("one", "2026-08-01T00:00:00Z"), now)
store.append(report("two", "2026-08-02T00:00:00Z"), now)
store.append(report("three", "2026-08-03T00:00:00Z"), now)
store.append(
report("four", "2026-08-04T00:00:00Z").copy(summary = "token=do-not-store"),
now,
)
val stored = store.readAll(now)
assertEquals(listOf("two", "three", "four"), stored.map { it.reportId })
assertFalse(stored.last().summary.contains("do-not-store"))
assertTrue(stored.last().pendingReview)
store.markReviewed("four", now)
assertFalse(store.readAll(now).last().pendingReview)
}
@Test
fun legacyMigrationIsVersionedPendingAndRedacted() {
val migrated = migrateLegacyCrash(
LegacyCrashSnapshot(
timeIso = "2026-08-03T15:12:26Z",
versionName = "1.6.0",
versionCode = 37,
flavor = "googlePlay",
manufacturer = "Example",
model = "Phone",
androidRelease = "17",
sdkInt = 37,
threadName = "main",
exceptionSummary = "Crash token=secret-value",
stackTrace = "at Example https://private.example.test/path",
),
reportId = "rpt-test",
appSessionId = "legacy-test",
)
assertEquals(RELIABILITY_SCHEMA_VERSION, migrated.schemaVersion)
assertEquals(ReliabilityKind.FatalCrash, migrated.kind)
assertTrue(migrated.pendingReview)
assertFalse(migrated.toPlainText().contains("secret-value"))
assertFalse(migrated.toPlainText().contains("private.example.test"))
}
@Test
fun supportBundleIsBoundedAndUsesExactRedactedReports() {
val reports = (1..12).map { index ->
report("r$index", "2026-08-${index.toString().padStart(2, '0')}T00:00:00Z")
.copy(technicalDetail = "token=secret-$index")
}
val bundle = SupportBundleBuilder.build(reports)
assertFalse(bundle.contains("secret-"))
assertFalse(bundle.contains("Report: r1\n"))
assertTrue(bundle.contains("Report: r12"))
assertEquals(10, Regex("===== Report ").findAll(bundle).count())
}
@Test
fun crashIssuePrefillTargetsAndroidAndContainsOnlyRedactedDetail() {
val report = report("rpt-prefill", "2026-08-04T00:00:00Z").copy(
summary = "Crash token=private-value",
technicalDetail = "at Example https://private.example.test/path",
)
val url = CrashReporter.buildGithubIssueUrl(report)
val decoded = URLDecoder.decode(url, "UTF-8")
assertTrue(decoded.contains("labels=bug,area:android"))
assertTrue(decoded.contains("### Affected area\nAndroid app"))
assertFalse(decoded.contains("private-value"))
assertFalse(decoded.contains("private.example.test"))
}
private fun report(id: String, time: String): ReliabilityReport = ReliabilityReport(
reportId = id,
appSessionId = "app-test",
timeIso = time,
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = "Unexpected problem",
recovery = "The app restarted",
reportRecommended = true,
technicalDetail = "java.lang.IllegalStateException",
environment = environment,
pendingReview = true,
)
}
@@ -0,0 +1,43 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.reliability.ReliabilityEnvironment
import com.hermesandroid.relay.reliability.ReliabilityKind
import com.hermesandroid.relay.reliability.ReliabilityOwner
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.ReliabilitySeverity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupportBundleDialogTest {
@Test
fun emptyStateCannotShare() {
val state = buildSupportReviewState(emptyList())
assertEquals(0, state.reportCount)
assertFalse(state.shareEnabled)
}
@Test
fun populatedStateShowsExactlyTheBundleThatCanBeShared() {
val report = ReliabilityReport(
reportId = "rpt-visible",
appSessionId = "app-visible",
timeIso = "2026-08-04T12:00:00Z",
kind = ReliabilityKind.RecoverableProductError,
owner = ReliabilityOwner.Voice,
severity = ReliabilitySeverity.Error,
summary = "Focus controls stopped responding",
recovery = "Voice closed and chat remained available",
reportRecommended = true,
environment = ReliabilityEnvironment("1.6.1", 38, "googlePlay", "Example", "Phone", "13", 33),
)
val state = buildSupportReviewState(listOf(report))
assertEquals(1, state.reportCount)
assertTrue(state.shareEnabled)
assertTrue(state.text.contains("Focus controls stopped responding"))
assertTrue(state.text.contains("Owner: Voice"))
}
}
@@ -115,7 +115,7 @@ class IssueReportAndDiagnosticsTest {
fun errorEntriesKeepBugTitleAndLabel() {
val entry = sampleEntry(DiagnosticSeverity.Error, title = "API key rejected")
assertEquals("[Bug]: API key rejected", DiagnosticIssuePrefill.issueTitle(entry))
assertEquals("bug", DiagnosticIssuePrefill.issueLabels(entry))
assertEquals("bug,area:android", DiagnosticIssuePrefill.issueLabels(entry))
}
@Test
@@ -124,7 +124,7 @@ class IssueReportAndDiagnosticsTest {
val entry = sampleEntry(severity)
assertEquals("[Diagnostic]: Testing API connection", DiagnosticIssuePrefill.issueTitle(entry))
// "question" already exists on the repo — the prefill must not invent labels.
assertEquals("question", DiagnosticIssuePrefill.issueLabels(entry))
assertEquals("question,area:android", DiagnosticIssuePrefill.issueLabels(entry))
}
}
@@ -161,6 +161,7 @@ class IssueReportAndDiagnosticsTest {
)
assertTrue(body.contains("- Connection mode: tailscale"))
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
assertFalse(body.contains("10.0.0.5"))
}
@Test
@@ -0,0 +1,269 @@
# Android reliability and support audit
**Date:** 2026-08-04
**Scope:** Android fatal crashes, handled failures, diagnostics, symbolication,
privacy, recovery, and user-initiated support submission.
## Executive summary
Hermes-Relay already has the right product posture: crash capture is local,
reports are never uploaded automatically, the platform crash handler still runs,
and users can copy, share, or open GitHub themselves. The implementation is not
yet one reliability system, however. Fatal crashes use a private one-file JSON
format, handled errors use an in-memory diagnostics ring, issue builders duplicate
environment and truncation rules, and release mappings are not retained outside
the transient build workspace. The UI therefore asks users to review developer
traces without enough interaction context while maintainers receive obfuscated or
truncated reports that cannot always be retraced.
The foundational change should align those seams without adding telemetry:
1. use one allowlisted, versioned reliability event contract for fatal and handled
failures;
2. redact locally before persistence, display, copy, share, or issue prefill;
3. retain a small deterministic local history and build a support export entirely
on device;
4. make the recovery UI lead with outcome and next steps, with technical detail
behind an explicit review action;
5. retain release mapping files outside public release assets by immutable
version/SHA and make Android's own issue prefills request the Android area.
No automatic upload, analytics SDK, remote crash service, prompt/message capture,
or background logcat collection is justified.
## Current end-to-end architecture
### Fatal crashes
- `HermesRelayApp.onCreate()` installs `CrashReporter` before other app setup.
- The process-wide uncaught-exception handler synchronously writes
`files/crash/last-crash.json`, then delegates to Android's previous handler.
This preserves the platform crash path and Google Play Android vitals.
- The next main-app launch displays `CrashReportGate`. The report remains until
the user dismisses, shares, or opens GitHub; copy alone does not acknowledge it.
- The persisted record contains time, version/code/flavor, manufacturer/model,
Android release/API, thread, exception summary, and the full Java trace.
- GitHub prefill truncates the trace to 3,000 characters because it is encoded in
a browser URL. Copy/share use the full persisted trace.
Failure modes:
- the raw exception message and trace are written before privacy redaction;
- a single non-atomic file means a second crash replaces the first and an
interrupted write can leave no readable report;
- the format has no explicit schema version, retention period, size bound, report
identifier, or migration contract;
- the primary recovery experience is a large stack trace rather than what
happened, what recovered, and what the user can do;
- a crash in a secondary process can compete for the same file;
- fatal OOM may leave too little memory for serialization or file I/O.
### Handled errors and coroutine failures
- `classifyError()` maps common network, auth, HTTP, SSL, permission, and voice
failures to `HumanError`, then records every non-null throwable in
`DiagnosticsLog`.
- `DiagnosticsLog` is an in-memory 200-entry ring. List fields are capped at 180
characters and error traces at 8,000 characters. It is cleared on process death.
- Network ownership is partially represented by `DiagnosticCategory` (`API`,
`Relay`, `Session`, `Voice`, `Route`, `Auth`), but `send_message` defaults to
API even when Gateway owns the turn. Dashboard and upstream Gateway do not have
first-class categories.
- Multiple long-lived `SupervisorJob` scopes exist in services, clients, and the
process runtime. There is no process-wide `CoroutineExceptionHandler`; failures
are visible only where a caller catches/classifies them or where they become
uncaught exceptions.
- Expected cancellation and user denial are not a first-class taxonomy. A caught
cancellation passed to `classifyError()` can appear as an error and become
reportable noise.
### Connectivity, auth, HTTP, WebSocket, and voice
- Typed DNS, connection-refused, timeout, TLS, permission, and generic I/O
failures receive humane copy and retry/repair hints.
- HTTP status handling is message-based. 401/403/404/413/500/503 have distinct
outcomes, but rate limiting is not first-class and ownership depends on a small
caller-provided context string.
- Gateway, Dashboard, API fallback, optional Relay, and Voice have separate live
checks in Diagnostics, but the captured error data contract cannot represent
all five owners precisely.
- `isConnectivityError()` lets startup UI avoid duplicate scary snackbars, but
those failures are still recorded as generic errors.
### OOM and ANR boundaries
- OOM is only captured if the uncaught handler has enough memory and storage to
finish. The current reporter allocates a `StringWriter` containing the complete
trace, which is specifically fragile during OOM.
- ANRs do not throw through the uncaught handler. Google Play can observe Play
builds, while sideload builds currently have no local ANR signal.
- Adding a main-thread watchdog immediately would introduce false positives during
debugger pauses, device sleep, startup, and legitimate long frames. It belongs
in a later opt-in/bounded phase after lifecycle-aware design and device testing.
### Diagnostics, breadcrumbs, and correlation
- Diagnostics has read-only subsystem checks plus the in-memory activity ring.
- Relay envelopes and some bridge/TUI paths have request IDs, and chat has durable
session/run/message IDs. These identifiers are not joined to crash reports.
- Raw session, profile, and connection identifiers may themselves be sensitive.
The safe foundation is a new random per-launch app-session ID and per-event
report ID. Product/session IDs should only be added later as short local hashes
after a demonstrated diagnostic need.
- There is no bounded breadcrumb contract. Capturing arbitrary diagnostic detail
would risk prompts, message text, media paths, profile names, and host data.
### R8, ProGuard, and symbolication
- Release builds enable R8 and preserve `SourceFile`/`LineNumberTable`; source file
names are normalized to `SourceFile`.
- AGP writes `mapping.txt` under `app/build/outputs/mapping/<variant>/`. The file
is overwritten by subsequent builds.
- The Play AAB contains its mapping, so Play reports can be deobfuscated in Play
Console. Sideload reports need the exact locally retained variant mapping.
- The release workflow publishes the sideload APK and Play AAB but does not retain
either release mapping as a versioned workflow artifact. A GitHub issue containing
a sideload trace therefore may be practically irretrievable after the runner is
gone.
- Maintainer procedure should be deterministic:
`retrace <mapping-for-exact-version-and-flavor> <trace-file>`.
### Recent-report evidence
- #289 (`1.6.0`, Play) contains a `NoSuchElementException` trace with application
frames reduced to names such as `gn5.g(SourceFile:2)` and is truncated mid-frame.
The interaction that preceded the crash is absent.
- #292 (`1.6.0-sideload`) contains a duplicate Compose lazy-list key but only
obfuscated application frames and a trace truncated by URL limits.
- #298 (`1.6.1`, Play) shows the same duplicate-key class on another device, again
with obfuscated application frames. There is no route/session/interaction
context to distinguish the owning list.
- #299 (`1.6.1`, Play) provides the missing human context—Focus-mode controls
animate but taps usually do not complete—but has no diagnostic event, app-session
correlation, or technical trail.
- All four Android issues received `area:plugin`. The former issue-triage workflow
tested broad `relay|plugin|...` keywords before Android terms, so the
repository/app name won before `Android app`, device, Compose, or voice context
was considered. Current `dev` has since retired that unreliable keyword
labeler in favor of maintainer review; it should not be reintroduced.
The reports demonstrate both halves of the gap: traces without interaction context
and interaction context without a safe technical trail.
## Privacy threat review
The following must never be collected by the reliability contract:
- authentication headers, cookies, API keys, Relay/session/pairing tokens, OAuth
codes, or signed URLs;
- prompt, response, transcript, reasoning, tool arguments/results, or notification
content;
- real hostnames, IP addresses, full URLs, SSIDs, proxy routes, or private
infrastructure names;
- profile/agent/person names, raw connection/session/run/message IDs, contacts, or
account identifiers;
- local/media/workspace paths, attachment names, clipboard contents, screenshots,
audio, or camera data.
The contract should allow only enumerated owner/kind/status values, version/device
metadata, random local correlation IDs, bounded redacted summaries/traces, route
roles (for example `lan` or `public TLS`, never the host), and bounded allowlisted
breadcrumbs with no arbitrary payload.
Redaction is defense in depth, not permission to collect prohibited fields. It
must run before disk persistence and again when rendering/exporting legacy data.
## Shared taxonomy and data contract
### Kinds
| Kind | Persistence / UI policy |
|---|---|
| Fatal crash | Persist synchronously; show recovery once; reporting is useful |
| ANR/watchdog signal | Contract reserved; later lifecycle-aware implementation |
| Recoverable product error | Persist bounded history; show owned recovery action |
| Connectivity | Low-noise; retry/offline guidance; do not nag for reports |
| Authentication | Name owning surface; repair/sign-in guidance |
| Rate limit | Show retry timing when safely known; do not report by default |
| Service unavailable | Retry guidance; report only if repeated/unexpected |
| Expected cancellation | Do not persist or offer reporting |
| User denial | Do not persist or offer reporting; explain how to change permission |
### Owners
`Android`, `Dashboard`, `API`, `Relay`, `Upstream Gateway`, `Voice`, and `Unknown`.
Standard Dashboard/Gateway/API ownership remains upstream; Relay is optional and
must never be presented as required for standard recovery.
### Versioned record
Each record contains: schema version, random report ID, random app-session ID,
timestamp, kind, owner, severity, humane summary, recovery outcome, whether a
report is recommended, bounded redacted technical detail, and allowlisted app /
OS / device / flavor metadata. Optional context contains route role and bounded
allowlisted breadcrumbs only.
## UX recommendation
### Post-crash recovery
Lead with “Hermes-Relay restarted after an unexpected problem.” Explain that work
on the Hermes server may still be running, no report was sent, and the user can
continue. Technical information stays collapsed behind “Review report.” Copy,
share, and GitHub use the same reviewed redacted payload. Dismiss remains the
lowest-friction path and never nags again for that event.
### Inline handled errors
Use owner + outcome + action: “Dashboard sign-in expired — chat can use API
fallback” or “Relay unavailable — standard Chat and Manage are unaffected.” Do not
offer reporting for connectivity, expected cancellation, user denial, or a missing
optional Relay feature. Keep report actions in Diagnostics rather than snackbars.
### Settings / About / Diagnostics
Diagnostics should expose “Review support information,” showing exactly the
bounded text that copy/share will receive. It should work offline and include no
new probe. About should continue to show version/flavor; duplicating export entry
points there is unnecessary in the foundation.
### Accessibility and localization
The crash dialog must support narrow/foldable layouts, scrolling, screen-reader
labels, large text, and an explicit technical-detail toggle. All new visible copy
must use resources across supported locales. Clipboard/share/browser absence must
fall back without losing the local report.
## Phased plan
### Immediate coherent foundation
- Add the versioned taxonomy/record and centralized local redactor.
- Persist fatal and centrally classified handled failures in a bounded atomic
local store; migrate the legacy one-file crash record.
- Suppress persistence/report prompting for expected cancellation and user denial.
- Build crash/support text and GitHub prefill from the same redacted contract.
- Add explicit review-before-sharing UI and a Diagnostics support export.
- Route Android crash and Diagnostics issue prefills to `area:android`; keep the
unreliable repository-wide keyword labeler disabled.
- Retain Play and sideload `mapping.txt` files as non-release version/SHA workflow
artifacts and document retrace.
- Add focused privacy, bounds, classification, migration, issue-prefill, and UI
state tests.
### Later, evidence-gated follow-up
- Lifecycle-aware ANR watchdog with debugger/sleep/startup suppression and device
false-positive testing.
- Strict allowlisted breadcrumbs at high-value transitions (screen/feature owner,
route transition, retry outcome), never user content.
- Hashed product correlation IDs only where a concrete diagnosis requires them.
- OOM emergency record preallocation / minimal writer.
- Broader structured error adoption at WebSocket, coroutine-scope, and service
boundaries that currently bypass `classifyError()`.
- Maintainer tooling that downloads the exact release mapping and runs retrace from
a report's version/code/flavor tuple.
Automatic telemetry, remote upload, full logcat collection, prompt/transcript
capture, and third-party crash SDKs remain out of scope unless separately proposed
and approved.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "e26a371ee7d835b718706bef24f4b21773897c1b6537728136d4879468722ffe",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+4
View File
@@ -31,6 +31,7 @@ All app data is stored on-device in the app's private sandbox:
| Relay session token | EncryptedSharedPreferences | Same encryption as API key |
| Theme and display preferences | DataStore preferences | Tool display mode, reasoning toggle, voice preferences |
| Stats for Nerds counters | DataStore preferences | Response times, token counts, health stats — local only |
| Reliability reports | App-private JSON | Up to 20 locally redacted crash/handled-error records, retained for 14 days; no prompts, messages, profile names, hosts, tokens, or media |
Chat messages are **not cached locally**. They are loaded from the Hermes API server on demand and exist only in memory while the app is running.
@@ -74,6 +75,9 @@ Notification companion is opt-in. The app only forwards notification metadata af
From Settings, users can:
- **Review support information** in Diagnostics, then explicitly copy or share
the exact redacted local text. Nothing is uploaded automatically.
- **Export** a full connection backup. The file includes server URLs,
preferences, API keys, relay session tokens, device IDs, and dashboard
cookies so restored connections can work without manual re-entry. Keep it