app: the history row was naming the wrong document's privacy level

A row read "22 kB · full" directly beneath "uploaded to fmr", while the status
line above said the upload went as BALANCED. Both were true and they described
different documents: the row showed ArchivedRun.anonymization, which describes
the *archived* copy - deliberately unredacted, so always "full" - and the status
line described the *uploaded* copy.

Read together, that says the complete data was uploaded when a redacted copy was
sent. A privacy display that overstates what left the device is worse than none,
and telling the user what left the device is the one thing this screen is for.

The level a run was uploaded at is now recorded separately (uploaded_as) and the
row says "kept complete on this device" / "uploaded to fmr as balanced" - each
label naming the copy it belongs to.

Two more from the same screenshot:

  - Every row showed no verdict. The archive read summary.verdict; the schema
    calls it summary.overall. Silently null on every run, so the list's most
    prominent element was blank while everything else looked fine. The test
    fixture had the same wrong field name, which is why it passed.
  - The status line rendered the server's raw JSON index entry into the UI.

Verified on device: a fresh run archives with verdict "yellow" and
uploaded_as "balanced" beside anonymization "full".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 15:37:05 +02:00
co-authored by Claude Fable 5
parent ac6c653115
commit 6bba420845
4 changed files with 63 additions and 10 deletions
@@ -72,12 +72,20 @@ fun HistoryScreen(
) )
} }
Text( Text(
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}", "${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · " +
"kept complete on this device",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
// The upload line names the level the upload was made at, not the
// archive's. They describe different documents, and showing the archive's
// level here claimed more had left the device than actually did.
Text( Text(
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}" if (r.uploaded) {
else "on this device only", "uploaded to ${r.uploadedTo ?: "a server"}" +
(r.uploadedAs?.let { " as $it" } ?: "")
} else {
"on this device only"
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB), color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
) )
@@ -164,8 +164,10 @@ class RunStore(context: Context, private val settings: Settings) {
) )
val body = redactedForUpload(docJson, level) val body = redactedForUpload(docJson, level)
val reply = client.uploadRun(settings.serverCredential, body) val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name) archive.markUploaded(runId, profile.name, level.wire)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") // Deliberately not echoing `reply`: it is the server's index entry as raw JSON, and
// it ended up rendered verbatim in the UI. Size and level are what a person wants.
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes")
} catch (e: VersionRefused) { } catch (e: VersionRefused) {
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version") UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
} catch (e: UploadRefused) { } catch (e: UploadRefused) {
@@ -31,10 +31,23 @@ data class ArchivedRun(
val verdict: String? = null, val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0, @SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0, @SerialName("size_bytes") val sizeBytes: Long = 0,
/**
* How the *archived* document is redacted. Always "full" in practice, because the archive
* deliberately keeps the unredacted run - see the package doc. This is not what was uploaded.
*/
val anonymization: String = "full", val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */ /** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false, val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null, @SerialName("uploaded_to") val uploadedTo: String? = null,
/**
* The level the run was *uploaded* at, which is a different document from the archived one.
*
* Kept separately because conflating the two is actively misleading: the history row showed
* the archive's own level ("full") directly beneath "uploaded to fmr", which reads as "the
* complete data was uploaded" when a redacted copy had been sent. A privacy display that
* overstates what left the device is worse than none.
*/
@SerialName("uploaded_as") val uploadedAs: String? = null,
) )
/** /**
@@ -119,7 +132,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
fun deleteAll(): Int = list().count { delete(it.id) } fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */ /** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) { fun markUploaded(id: String, serverName: String, uploadedAs: String? = null) {
val f = File(dir, safe(id) + META_EXT) val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull() val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return ?: return
@@ -127,7 +140,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
f, f,
json.encodeToString( json.encodeToString(
ArchivedRun.serializer(), ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName), meta.copy(uploaded = true, uploadedTo = serverName, uploadedAs = uploadedAs),
), ),
) )
} }
@@ -181,7 +194,10 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
id = id, id = id,
savedAtEpochMs = now(), savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content, startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content, // The schema calls it `overall` (Summary.overall); reading `verdict` here silently
// yielded null for every run, so the history list's most prominent element - the
// coloured verdict - was blank on every row.
verdict = doc["summary"]?.jsonObject?.get("overall")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0, findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size, sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full", anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
@@ -25,7 +25,7 @@ class RunArchiveTest {
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String { private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" } val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" + return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}""" """"findings":[$f],"summary":{"overall":"warn"},"pad":"${"x".repeat(pad)}"}"""
} }
@Test @Test
@@ -120,13 +120,40 @@ class RunArchiveTest {
fun uploadStateIsRecorded() { fun uploadStateIsRecorded() {
val a = archive() val a = archive()
a.save(doc("run-1")) a.save(doc("run-1"))
a.markUploaded("run-1", "fmr") a.markUploaded("run-1", "fmr", "balanced")
val meta = a.list().single() val meta = a.list().single()
assertTrue(meta.uploaded) assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo) assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry") assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
} }
// The archive's own level and the level a run was uploaded at describe *different documents*.
// Showing the archive's ("full", because the archive is deliberately unredacted) next to
// "uploaded to fmr" reads as "the complete data was uploaded" when a redacted copy was sent —
// a privacy display that overstates what left the device is worse than none.
@Test
fun theUploadedLevelIsRecordedSeparatelyFromTheArchivedOne() {
val a = archive()
// A real archived document carries no privacy stamp: the anonymizer never runs on the
// archive. The shared doc() fixture has one, which is exactly the unrealism that let this
// confusion through in the first place.
a.save("""{"run":{"id":"run-1"},"findings":[],"summary":{"overall":"green"}}""")
a.markUploaded("run-1", "fmr", "balanced")
val meta = a.list().single()
assertEquals("full", meta.anonymization, "the archived copy is unredacted, by design")
assertEquals("balanced", meta.uploadedAs, "the uploaded copy was redacted, and must say so")
}
// The verdict is read from `summary.overall` — the schema's actual field name. Reading
// `summary.verdict` silently yielded null for every run, so the history list's most prominent
// element was blank on every row while everything else looked fine.
@Test
fun theVerdictComesFromTheSchemasOverallField() {
val a = archive()
a.save("""{"run":{"id":"r1"},"findings":[],"summary":{"overall":"yellow"}}""")
assertEquals("yellow", a.list().single().verdict)
}
@Test @Test
fun deleteRemovesBothFiles() { fun deleteRemovesBothFiles() {
val a = archive() val a = archive()