Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions clients/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ dependencies {
// file-by-file engine; the CLI/CI side generates patches with the SAME jar.
implementation("com.eidu:archive-patcher:3.0.0")
testImplementation("junit:junit:4.13.2")
// JVM unit tests otherwise load the mockable android.jar stub, whose
// org.json.JSONObject throws "not mocked". This jar is the real implementation.
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core-ktx:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
Expand All @@ -151,6 +154,23 @@ tasks.matching { it.name == "testReleaseUnitTest" }.configureEach {
dependsOn(testNativeRecordIdentity)
}

tasks.withType<Test>().configureEach {
if (!name.contains("UnitTest")) return@configureEach
// The mockable android.jar is earlier on the test classpath than Maven
// dependencies, so put/has hit the stub. Move the real org.json jar first.
doFirst {
val all = classpath.files.toList()
val jsonJars = all.filter { it.name.matches(Regex("""json-\d.*\.jar""")) }
if (jsonJars.isEmpty()) {
throw GradleException("$name cannot run sidecar JSON cases without org.json on the classpath")
}
val rest = all.filterNot { it.name.matches(Regex("""json-\d.*\.jar""")) }
// Test.classpath is a FileCollection. Assignment replaces the whole
// collection; classpath.setFrom is not visible from Kotlin.
classpath = files(jsonJars + rest)
}
}

tasks.withType<PublishToMavenLocal>().configureEach {
dependsOn(validatePublicationVersion, verifyReleaseElfAlignment)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,14 @@ internal object HandsAnr {
if (watermark == null) prefs.edit().putLong(KEY_WATERMARK, System.currentTimeMillis() - FIRST_RUN_LOOKBACK_MS).apply()
return
}
// ApplicationExitInfo has no app version. Leave the ticket build empty
// instead of stamping versionName/versionCode from the uploading install.
val feedback = HandsFeedback(
context = context,
baseUrl = baseUrl,
appSlug = appSlug,
versionName = versionName,
versionCode = versionCode,
versionName = null,
versionCode = null,
channel = channel,
clientKey = clientKey,
)
Expand Down
85 changes: 63 additions & 22 deletions clients/android/src/main/kotlin/build/hands/update/HandsCrash.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ object HandsCrash {
// Persist the release-health crash marker before doing any heavier
// crash-log work. It is flushed on the next launch.
runCatching { HandsSessions.markCurrentCrashed() }
val crashedBuild = installedBuild(appContext)
val crashLog =
runCatching { buildCrashLog(appContext, thread, throwable, extraContext) }
runCatching { buildCrashLog(appContext, thread, throwable, extraContext, crashedBuild) }
.getOrElse { buildFallbackCrashLog(thread, throwable, it) }
.take(CRASH_LOG_MAX_CHARS)

runCatching { writeCrash(appContext, thread, throwable, crashLog) }
runCatching { writeCrash(appContext, thread, throwable, crashLog, crashedBuild) }
.onFailure { Log.e(TAG, "Failed to write crash log", it) }
if (copyToClipboard) {
runCatching {
Expand Down Expand Up @@ -137,6 +138,11 @@ object HandsCrash {
* Upload stored crashes through the feedback channel and delete them on
* success. Safe to call repeatedly; runs synchronously on the calling
* thread.
*
* [versionName] and [versionCode] are the install that is uploading.
* A stored crash keeps the build captured when it happened. An older
* sidecar with no build keys is recovered from the log. An unknown build
* is omitted. Neither path copies the uploading install onto the ticket.
*/
fun uploadPending(
context: Context,
Expand All @@ -156,8 +162,8 @@ object HandsCrash {
context = context,
baseUrl = baseUrl,
appSlug = appSlug,
versionName = versionName,
versionCode = versionCode,
versionName = null,
versionCode = null,
channel = channel,
clientKey = clientKey,
)
Expand All @@ -168,6 +174,8 @@ object HandsCrash {
continue
}
val meta = runCatching { JSONObject(sidecar.readText()) }.getOrNull() ?: JSONObject()
val logText = runCatching { logFile.readText() }.getOrDefault("")
val historical = historicalBuildFromSidecar(meta, logText)
val exceptionClass = meta.optString("exception_class", "UnknownException")
val topFrame = meta.optString("top_frame", "")
val message =
Expand All @@ -185,14 +193,15 @@ object HandsCrash {
kind = "crash",
attachments = listOf(logFile),
extras =
mapOf(
"crash_exception_class" to exceptionClass,
"crash_top_frame" to topFrame,
"crash_thread" to meta.optString("thread", ""),
"crash_at" to meta.optLong("crash_at", 0L),
"crash_process_uptime_ms" to meta.optLong("process_uptime_ms", -1L),
"breadcrumbs" to meta.optString("breadcrumbs", "[]"),
),
buildMap {
put("crash_exception_class", exceptionClass)
put("crash_top_frame", topFrame)
put("crash_thread", meta.optString("thread", ""))
put("crash_at", meta.optLong("crash_at", 0L))
put("crash_process_uptime_ms", meta.optLong("process_uptime_ms", -1L))
put("breadcrumbs", meta.optString("breadcrumbs", "[]"))
putAll(HandsHistoricalBuildPolicy.versionExtras(historical))
},
)
}
}
Expand All @@ -215,11 +224,49 @@ object HandsCrash {
return dir
}

internal fun historicalBuildFromSidecar(meta: JSONObject, logText: String): HandsHistoricalBuild {
val namePresent = meta.has("version_name")
val codePresent = meta.has("version_code")
val name = if (!namePresent || meta.isNull("version_name")) {
null
} else {
meta.optString("version_name", "")
}
val code = if (!codePresent || meta.isNull("version_code")) {
null
} else {
HandsHistoricalBuildPolicy.versionCodeOrNull(meta.get("version_code"))
}
return HandsHistoricalBuildPolicy.resolve(
versionNamePresent = namePresent,
versionName = name,
versionCodePresent = codePresent,
versionCode = code,
logText = logText,
)
}

private fun installedBuild(context: Context): HandsHistoricalBuild =
runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val code =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION") packageInfo.versionCode.toLong()
}
HandsHistoricalBuild(
versionName = packageInfo.versionName?.trim()?.takeIf { it.isNotEmpty() },
versionCode = code.takeIf { HandsHistoricalBuildPolicy.versionCodeOrNull(it) != null },
)
}.getOrDefault(HandsHistoricalBuild(null, null))

private fun writeCrash(
context: Context,
thread: Thread,
throwable: Throwable,
crashLog: String,
crashedBuild: HandsHistoricalBuild,
) {
val dir = crashDir(context) ?: return
// Cap retention: keep the newest MAX_STORED_CRASHES - 1 before adding.
Expand All @@ -244,6 +291,8 @@ object HandsCrash {
.put("thread", thread.name)
.put("crash_at", System.currentTimeMillis())
.put("process_uptime_ms", System.currentTimeMillis() - processStartMs)
.put("version_name", crashedBuild.versionName ?: "")
.put("version_code", crashedBuild.versionCode ?: JSONObject.NULL)
runCatching { meta.put("breadcrumbs", HandsCapture.snapshotBreadcrumbs()) }
File("${base.absolutePath}.meta.json").writeText(meta.toString())
Log.e(TAG, "Crash log written to: ${base.absolutePath}.txt")
Expand All @@ -254,21 +303,13 @@ object HandsCrash {
thread: Thread,
throwable: Throwable,
extraContext: (() -> String)?,
crashedBuild: HandsHistoricalBuild,
): String =
buildString {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
appendLine("Crash log")
appendLine("Crash at: ${Date()}")
appendLine("Package: ${context.packageName}")
appendLine("Version name: ${packageInfo.versionName.orEmpty()}")
appendLine(
"Version code: " +
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString()
} else {
@Suppress("DEPRECATION") packageInfo.versionCode.toString()
},
)
append(HandsHistoricalBuildPolicy.crashLogVersionLines(crashedBuild))
appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}".trim())
appendLine("Android: ${Build.VERSION.RELEASE} / SDK ${Build.VERSION.SDK_INT}")
appendLine("Device id: ${HandsDeviceId.get(context)}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package build.hands.update

/**
* Build identity for a crash uploaded by a later process.
*
* The uploading install is intentionally not an input. A stored sidecar key
* is explicit, including an empty name or a null code. An older sidecar with
* neither key is recovered from its log. Otherwise the ticket build stays
* empty, so symbolication cannot attach the wrong package.
*/
internal data class HandsHistoricalBuild(
val versionName: String?,
val versionCode: Long?,
)

internal object HandsHistoricalBuildPolicy {
private const val MAX_SAFE_INTEGER = 9007199254740991L

/** Version lines written into a Java crash log. Empty values stay on their own line. */
fun crashLogVersionLines(build: HandsHistoricalBuild): String = buildString {
append("Version name: ")
append(build.versionName.orEmpty())
append('\n')
append("Version code: ")
append(build.versionCode?.toString().orEmpty())
append('\n')
}

fun fromLog(text: String): HandsHistoricalBuild {
val normalized = text.replace("\r\n", "\n").replace('\r', '\n')
return ticket(
name = firstMatch(normalized, HEADER_NAME, REPORTED_NAME, BUNDLE_NAME),
code = firstCode(normalized, HEADER_CODE, REPORTED_CODE),
)
}

/**
* Recorded keys win as a pair: if either key was stored, the missing one
* stays missing. Only a sidecar with neither key is recovered from [logText].
*/
fun resolve(
versionNamePresent: Boolean,
versionName: String?,
versionCodePresent: Boolean,
versionCode: Long?,
logText: String,
): HandsHistoricalBuild {
if (!versionNamePresent && !versionCodePresent) {
return fromLog(logText)
}
val name = if (versionNamePresent) versionName?.trim().orEmpty() else ""
val code = if (versionCodePresent) versionCode?.takeIf(::isReportableCode) else null
return ticket(name, code)
}

/** Metadata fields for one historical ticket. Absent fields are omitted, never backfilled. */
fun versionExtras(build: HandsHistoricalBuild): Map<String, Any> = buildMap {
build.versionName?.let { put("version_name", it) }
build.versionCode?.let { put("version_code", it) }
}

fun versionCodeOrNull(value: Any?): Long? {
val number = value as? Number ?: return null
val asLong = when (number) {
is Long -> number
is Int, is Short, is Byte -> number.toLong()
else -> {
val doubleValue = number.toDouble()
if (!doubleValue.isFinite() || doubleValue % 1.0 != 0.0) return null
if (doubleValue < 0.0 || doubleValue > MAX_SAFE_INTEGER.toDouble()) return null
doubleValue.toLong()
}
}
return asLong.takeIf(::isReportableCode)
}

private fun ticket(name: String, code: Long?): HandsHistoricalBuild =
HandsHistoricalBuild(
versionName = name.trim().takeIf { it.isNotEmpty() },
versionCode = code,
)

private fun isReportableCode(code: Long): Boolean = code in 0..MAX_SAFE_INTEGER

private fun firstMatch(text: String, vararg patterns: Regex): String {
for (pattern in patterns) {
val value = pattern.find(text)?.groupValues?.getOrNull(1)?.trim().orEmpty()
if (value.isNotEmpty()) return value
}
return ""
}

private fun firstCode(text: String, vararg patterns: Regex): Long? {
for (pattern in patterns) {
val raw = pattern.find(text)?.groupValues?.getOrNull(1) ?: continue
versionCodeOrNull(raw.toLongOrNull())?.let { return it }
}
return null
}

// Whitespace stays on the same line. `\s` would cross the newline and
// treat the next field as the version.
private val HEADER_NAME = Regex("(?m)^Version:[ \\t]*(\\S.*?)[ \\t]*\$")
private val REPORTED_NAME = Regex("(?m)^Version name:[ \\t]*(\\S.*?)[ \\t]*\$")
private val BUNDLE_NAME = Regex("(?m)^Bundle version:[ \\t]*(\\S.*?)[ \\t]*\$")
private val HEADER_CODE = Regex("(?m)^VersionCode:[ \\t]*(\\d+)[ \\t]*\$")
private val REPORTED_CODE = Regex("(?m)^Version code:[ \\t]*(\\d+)[ \\t]*\$")
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ object HandsNativeCrash {
}
}

// A QNC record has no crash-time version. Leave the ticket build empty
// instead of stamping versionName/versionCode from the uploading install.
val feedback = HandsFeedback(
context = context,
baseUrl = baseUrl,
appSlug = appSlug,
versionName = versionName,
versionCode = versionCode,
versionName = null,
versionCode = null,
channel = channel,
clientKey = clientKey,
)
Expand Down
Loading
Loading