Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dcb280eff | ||
|
|
a90067292a |
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user