Skip to content

Commit d545af5

Browse files
malysclaude
andcommitted
feat(diag): shared crash logger and PrivateBin uploader
MG4Control and MG4Tasker each carried their own CrashLogger, and they had diverged: MG4Control chained no previous handler and named the file crash_log.txt, MG4Tasker counted characters where the constant promised bytes and dropped the cause chain. Each copy had a fix the other lacked. Move both into com.mg4.hardware.diag, merging the fixes: chained handler, cause chain spelled out, byte-safe UTF-8 truncation that keeps the head, atomic write. PrivateBin moves verbatim — it had no Android or app dependency to begin with. Engine only. Rendering a report and deciding when to paste it stay in the apps, which is where the UI lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 821bbfb commit d545af5

6 files changed

Lines changed: 653 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Shared diagnostic engine** in `com.mg4.hardware.diag`, so the apps stop each carrying
12+
their own copy:
13+
- `CrashLogger` writes an uncaught exception, its cause chain and the `AppLogger` buffer
14+
to `filesDir/last_crash.txt`, chaining the previous handler and truncating from the tail
15+
so the exception survives. It replaces the two divergent copies in MG4Control and
16+
MG4Tasker, each of which carried a fix the other was missing.
17+
- `PrivateBin` uploads a report to a PrivateBin instance, zero-knowledge — the key never
18+
leaves the URL fragment. Moved verbatim from MG4Tasker.
19+
No UI: rendering a report and deciding when to paste it stay in the apps.
20+
921
### Changed
1022

1123
- **One "Call" action.** `CALL_NUMBER` now carries a `CONTACT` value, so the same entry takes

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ here.
4444
| Models | `DrivingProfile`, `DriveMode`, `RegenLevel`, `ProfileBackup` |
4545
| Catalogue | `ConditionType`, `ActionType`, `ValueSpec`, `VehicleEnums`, `SnapshotKeys` |
4646
| Compatibility | `@SupportedOn`, `FirmwareSupport`, `FirmwareMatrix` → [docs/firmware-matrix.md](docs/firmware-matrix.md) |
47-
| Diagnostics | `AppLogger` (ring buffer, no ADB needed) |
47+
| Diagnostics | `AppLogger` (ring buffer, no ADB needed), `diag.CrashLogger`, `diag.PrivateBin` |
4848

4949
### The safety gate and `@RequiresStandstill`
5050

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package com.mg4.hardware.diag
2+
3+
import android.content.Context
4+
import androidx.annotation.VisibleForTesting
5+
import com.mg4.hardware.AppLogger
6+
import java.io.File
7+
import java.io.PrintWriter
8+
import java.io.StringWriter
9+
import java.text.SimpleDateFormat
10+
import java.util.Date
11+
import java.util.Locale
12+
13+
/**
14+
* Captures uncaught exceptions to a file in `filesDir`, surfaced on the next launch.
15+
*
16+
* On a vehicle there is no crash reporter and usually no cable. Without this, a crash at
17+
* ignition leaves nothing behind: the process dies, the user sees the app "not working",
18+
* and there is no way to find out why after the fact.
19+
*
20+
* It lives here rather than in each app because every app in the suite crashes on the same
21+
* head unit for the same reasons, and the two copies that preceded this one had each fixed
22+
* a bug the other still carried — one chained the previous handler, the other counted bytes
23+
* instead of characters when truncating.
24+
*
25+
* The previous handler is always chained, so the system still gets to do its job.
26+
*/
27+
object CrashLogger {
28+
29+
private const val TAG = "Crash"
30+
private const val FILE_NAME = "last_crash.txt"
31+
32+
/**
33+
* Reports are truncated by keeping the HEAD, not the tail.
34+
*
35+
* The exception and its top frames are at the top of the report — that is the part
36+
* that identifies the bug. Cutting from the start would discard exactly what is
37+
* needed and keep the least interesting frames.
38+
*/
39+
private const val MAX_BYTES = 64 * 1024
40+
41+
/** How far down the `cause` chain to walk, and how many frames to keep per cause. */
42+
private const val MAX_CAUSE_DEPTH = 5
43+
private const val CAUSE_FRAMES = 20
44+
45+
/**
46+
* Installs the handler. [appName] only titles the report — it is read by a human, and
47+
* the package name alone does not say which app of the suite produced it.
48+
*/
49+
fun install(context: Context, appName: String) {
50+
val appContext = context.applicationContext
51+
val previous = Thread.getDefaultUncaughtExceptionHandler()
52+
53+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
54+
AppLogger.e(TAG, "uncaught exception on thread '${thread.name}': $throwable")
55+
runCatching { write(appContext, appName, thread, throwable) }
56+
// Chain, never swallow: the platform still needs to terminate the process,
57+
// and swallowing here would leave the app in an undefined state instead.
58+
previous?.uncaughtException(thread, throwable)
59+
}
60+
}
61+
62+
fun hasReport(context: Context): Boolean = file(context).exists()
63+
64+
fun read(context: Context): String? =
65+
file(context).takeIf { it.exists() }?.runCatching { readText() }?.getOrNull()
66+
67+
fun clear(context: Context) {
68+
runCatching { file(context).delete() }
69+
}
70+
71+
@VisibleForTesting
72+
internal fun write(context: Context, appName: String, thread: Thread, throwable: Throwable) {
73+
val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
74+
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())
75+
76+
val report = buildString {
77+
appendLine("$appName crash report")
78+
appendLine("time : $timestamp")
79+
appendLine("thread : ${thread.name}")
80+
appendLine("version : ${version(context)}")
81+
appendLine("android : ${android.os.Build.VERSION.SDK_INT}")
82+
appendLine("device : ${android.os.Build.DEVICE}")
83+
appendLine()
84+
appendLine("-- Stack trace --")
85+
appendLine(stackTrace)
86+
appendCauses(throwable)
87+
appendLine("-- Recent log (AppLogger) --")
88+
appendLine(AppLogger.dump())
89+
}
90+
91+
writeAtomically(file(context), truncate(report))
92+
}
93+
94+
/**
95+
* `printStackTrace` already prints the causes, but abbreviates them to the frames that
96+
* differ from the enclosing trace. On a reflection layer the interesting frame is
97+
* usually one of the common ones, so the chain is spelled out again in full.
98+
*/
99+
private fun StringBuilder.appendCauses(throwable: Throwable) {
100+
var cause = throwable.cause
101+
var depth = 0
102+
while (cause != null && depth < MAX_CAUSE_DEPTH) {
103+
appendLine("-- Caused by --")
104+
appendLine(cause.toString())
105+
cause.stackTrace.take(CAUSE_FRAMES).forEach { appendLine(" at $it") }
106+
appendLine()
107+
cause = cause.cause
108+
depth++
109+
}
110+
}
111+
112+
/**
113+
* Truncates to [MAX_BYTES] keeping the BEGINNING, and counts bytes rather than
114+
* characters: the log buffer is full of non-ASCII, which is two bytes in UTF-8, so a
115+
* character count writes up to twice the announced ceiling.
116+
*/
117+
@VisibleForTesting
118+
internal fun truncate(content: String): ByteArray {
119+
val bytes = content.toByteArray(Charsets.UTF_8)
120+
if (bytes.size <= MAX_BYTES) return bytes
121+
122+
val marker = "\n... report truncated (${bytes.size} bytes) ...\n".toByteArray(Charsets.UTF_8)
123+
// Never cut mid-character: back up over UTF-8 continuation bytes (10xxxxxx).
124+
var end = MAX_BYTES - marker.size
125+
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
126+
return bytes.copyOfRange(0, end) + marker
127+
}
128+
129+
/**
130+
* Temp file then rename over the target.
131+
*
132+
* Never delete-then-write: that leaves a window with no file at all, and this runs
133+
* while the process is already dying — a second failure mid-write would destroy the
134+
* previous report without producing a new one.
135+
*/
136+
private fun writeAtomically(target: File, content: ByteArray) {
137+
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
138+
try {
139+
temp.writeBytes(content)
140+
if (!temp.renameTo(target)) {
141+
temp.delete()
142+
AppLogger.w(TAG, "could not rename crash report into place")
143+
}
144+
} catch (e: Exception) {
145+
temp.delete()
146+
AppLogger.w(TAG, "could not write crash report: ${e.message}")
147+
}
148+
}
149+
150+
private fun version(context: Context): String = runCatching {
151+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
152+
}.getOrNull() ?: "?"
153+
154+
private fun file(context: Context) = File(context.filesDir, FILE_NAME)
155+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package com.mg4.hardware.diag
2+
3+
import com.google.gson.JsonObject
4+
import com.google.gson.JsonParser
5+
import java.io.ByteArrayOutputStream
6+
import java.math.BigInteger
7+
import java.net.HttpURLConnection
8+
import java.net.URL
9+
import java.security.SecureRandom
10+
import java.util.Base64
11+
import java.util.zip.Deflater
12+
import javax.crypto.Cipher
13+
import javax.crypto.Mac
14+
import javax.crypto.spec.GCMParameterSpec
15+
import javax.crypto.spec.SecretKeySpec
16+
17+
/**
18+
* Uploads a report to a PrivateBin instance.
19+
*
20+
* PrivateBin is zero-knowledge: the server stores ciphertext and never sees the key, which
21+
* travels in the URL fragment and is therefore never sent in an HTTP request. That is what
22+
* makes pasting a vehicle diagnostic — VIN-free, but full of firmware and rule detail —
23+
* acceptable at all. The extra password means the link alone is not enough either.
24+
*
25+
* The format is PrivateBin's v2 API and every constant here is load-bearing:
26+
*
27+
* - the key is 32 random bytes, base58 in the fragment;
28+
* - PBKDF2-HMAC-SHA256 over `key ‖ password`, 100 000 iterations, 8-byte salt;
29+
* - AES-256-GCM, 16-byte IV, 128-bit tag;
30+
* - the authenticated data is the **exact** JSON text of `adata` as it is sent, because the
31+
* browser that later decrypts re-serialises the array it received with `JSON.stringify`.
32+
* Reordering it, adding a space, or letting a JSON library format it breaks decryption on
33+
* the reader's side while the upload still succeeds — so `adata` is built as a string
34+
* here and embedded verbatim;
35+
* - the plaintext is raw DEFLATE (no zlib header) of `{"paste":"…"}`.
36+
*
37+
* No Android dependency: the whole encoding is unit-testable on the JVM, and [poster] is
38+
* injectable so a test can assert the wire format without a network.
39+
*/
40+
object PrivateBin {
41+
42+
/** PrivateBin's own defaults; the instance may shorten [expire], never lengthen it. */
43+
data class Config(
44+
val baseUrl: String,
45+
val password: String,
46+
val expire: String,
47+
val formatter: String,
48+
)
49+
50+
sealed interface Outcome {
51+
/** The paste URL, key fragment included — the only form that can be read back. */
52+
data class Ok(val url: String) : Outcome
53+
data class Failed(val reason: String) : Outcome
54+
}
55+
56+
private const val ITERATIONS = 100_000
57+
private const val KEY_BITS = 256
58+
private const val TAG_BITS = 128
59+
private const val KEY_BYTES = 32
60+
private const val IV_BYTES = 16
61+
private const val SALT_BYTES = 8
62+
63+
private const val CONNECT_TIMEOUT_MS = 15_000
64+
private const val READ_TIMEOUT_MS = 30_000
65+
66+
fun paste(
67+
text: String,
68+
config: Config,
69+
random: SecureRandom = SecureRandom(),
70+
poster: (String, String) -> String = ::post,
71+
): Outcome {
72+
val key = ByteArray(KEY_BYTES).also(random::nextBytes)
73+
val iv = ByteArray(IV_BYTES).also(random::nextBytes)
74+
val salt = ByteArray(SALT_BYTES).also(random::nextBytes)
75+
76+
val adata = adataJson(iv, salt, config.formatter)
77+
val derived = deriveKey(key, config.password, salt)
78+
// Built element by element rather than reflected off a map: a consumer has already
79+
// been bitten once by R8 stripping the generic signatures Gson's reflective path
80+
// needs, and a payload that only fails in a minified build is the worst kind.
81+
val payload = JsonObject().apply { addProperty("paste", text) }.toString()
82+
val plain = rawDeflate(payload.toByteArray())
83+
val cipherText = encrypt(derived, iv, plain, adata.toByteArray())
84+
85+
val body = """{"v":2,"adata":$adata,"ct":"${base64(cipherText)}",""" +
86+
""""meta":{"expire":"${config.expire}"}}"""
87+
88+
val response = try {
89+
poster(config.baseUrl, body)
90+
} catch (e: Exception) {
91+
return Outcome.Failed(e.message ?: e.javaClass.simpleName)
92+
}
93+
94+
val json = runCatching { JsonParser.parseString(response).asJsonObject }.getOrNull()
95+
?: return Outcome.Failed("unreadable response")
96+
if (json.get("status")?.asInt != 0) {
97+
return Outcome.Failed(json.get("message")?.asString ?: "refused by the server")
98+
}
99+
val path = json.get("url")?.asString ?: return Outcome.Failed("no url in the response")
100+
return Outcome.Ok(
101+
config.baseUrl.trimEnd('/') + "/" + path.trimStart('/') + "#" + base58(key)
102+
)
103+
}
104+
105+
// -------------------------------------------------------------------------
106+
107+
/**
108+
* `JSON.stringify` of the metadata array, byte for byte: no spaces, this field order.
109+
* Both the server's signature check and the reader's decryption depend on it.
110+
*/
111+
private fun adataJson(iv: ByteArray, salt: ByteArray, formatter: String): String =
112+
"""[["${base64(iv)}","${base64(salt)}",$ITERATIONS,$KEY_BITS,$TAG_BITS,""" +
113+
""""aes","gcm","zlib"],"$formatter",0,0]"""
114+
115+
/**
116+
* PBKDF2-HMAC-SHA256 over the raw key bytes with the password appended.
117+
*
118+
* Hand-rolled rather than `SecretKeyFactory`, which takes a `char[]` and would have to
119+
* encode 32 random bytes as text to accept them — there is no encoding that survives
120+
* that round trip, and PrivateBin hashes the bytes.
121+
*/
122+
private fun deriveKey(key: ByteArray, password: String, salt: ByteArray): ByteArray {
123+
val secret = key + password.toByteArray()
124+
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret, "HmacSHA256")) }
125+
val out = ByteArray(KEY_BITS / 8)
126+
var offset = 0
127+
var block = 1
128+
while (offset < out.size) {
129+
var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, block.toByte()))
130+
val acc = u.copyOf()
131+
repeat(ITERATIONS - 1) {
132+
u = mac.doFinal(u)
133+
for (i in acc.indices) acc[i] = (acc[i].toInt() xor u[i].toInt()).toByte()
134+
}
135+
val take = minOf(acc.size, out.size - offset)
136+
acc.copyInto(out, offset, 0, take)
137+
offset += take
138+
block++
139+
}
140+
return out
141+
}
142+
143+
private fun encrypt(key: ByteArray, iv: ByteArray, plain: ByteArray, aad: ByteArray): ByteArray =
144+
Cipher.getInstance("AES/GCM/NoPadding").run {
145+
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, iv))
146+
updateAAD(aad)
147+
doFinal(plain)
148+
}
149+
150+
/** Raw DEFLATE — no zlib wrapper, which is what PrivateBin's "zlib" compression means. */
151+
private fun rawDeflate(data: ByteArray): ByteArray {
152+
val deflater = Deflater(Deflater.BEST_COMPRESSION, true)
153+
return try {
154+
deflater.setInput(data)
155+
deflater.finish()
156+
val out = ByteArrayOutputStream(data.size)
157+
val buffer = ByteArray(8 * 1024)
158+
while (!deflater.finished()) out.write(buffer, 0, deflater.deflate(buffer))
159+
out.toByteArray()
160+
} finally {
161+
deflater.end()
162+
}
163+
}
164+
165+
private fun base64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
166+
167+
/** Bitcoin alphabet, as PrivateBin's fragment encoding uses. */
168+
private fun base58(data: ByteArray): String {
169+
val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
170+
var value = BigInteger(1, data)
171+
val fifty8 = BigInteger.valueOf(58)
172+
val out = StringBuilder()
173+
while (value.signum() > 0) {
174+
val (quotient, remainder) = value.divideAndRemainder(fifty8)
175+
out.append(alphabet[remainder.toInt()])
176+
value = quotient
177+
}
178+
// Leading zero bytes carry no magnitude, so they must be re-added explicitly.
179+
for (byte in data) {
180+
if (byte.toInt() != 0) break
181+
out.append(alphabet[0])
182+
}
183+
return out.reverse().toString()
184+
}
185+
186+
private fun post(url: String, body: String): String {
187+
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
188+
requestMethod = "POST"
189+
doOutput = true
190+
connectTimeout = CONNECT_TIMEOUT_MS
191+
readTimeout = READ_TIMEOUT_MS
192+
setRequestProperty("Content-Type", "application/json")
193+
// PrivateBin answers HTML to anything that does not claim to be its own client.
194+
setRequestProperty("X-Requested-With", "JSONHttpRequest")
195+
}
196+
return try {
197+
connection.outputStream.use { it.write(body.toByteArray()) }
198+
val stream = if (connection.responseCode in 200..299) connection.inputStream
199+
else connection.errorStream
200+
stream?.bufferedReader()?.use { it.readText() }
201+
?: throw IllegalStateException("HTTP ${connection.responseCode}, empty body")
202+
} finally {
203+
connection.disconnect()
204+
}
205+
}
206+
}

0 commit comments

Comments
 (0)