From 6bba420845b5a318ab5cc673ffbbdc28356f92d1 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 15:37:05 +0200 Subject: [PATCH] app: the history row was naming the wrong document's privacy level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/app/echo_lot/app/HistoryScreen.kt | 14 +++++++-- .../main/kotlin/app/echo_lot/app/RunStore.kt | 6 ++-- .../kotlin/app/echo_lot/archive/RunArchive.kt | 22 +++++++++++-- .../app/echo_lot/archive/RunArchiveTest.kt | 31 +++++++++++++++++-- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt index 96de162..033979e 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/HistoryScreen.kt @@ -72,12 +72,20 @@ fun HistoryScreen( ) } 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, ) + // 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( - if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}" - else "on this device only", + if (r.uploaded) { + "uploaded to ${r.uploadedTo ?: "a server"}" + + (r.uploadedAs?.let { " as $it" } ?: "") + } else { + "on this device only" + }, style = MaterialTheme.typography.bodySmall, color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB), ) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index bae752e..3ae07c4 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -164,8 +164,10 @@ class RunStore(context: Context, private val settings: Settings) { ) val body = redactedForUpload(docJson, level) val reply = client.uploadRun(settings.serverCredential, body) - archive.markUploaded(runId, profile.name) - UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") + archive.markUploaded(runId, profile.name, level.wire) + // 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) { UploadOutcome.Incompatible(e.message ?: "the server refused this app's version") } catch (e: UploadRefused) { diff --git a/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt b/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt index 3ca15af..a572c39 100644 --- a/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt +++ b/echolot-app/core-archive/src/main/kotlin/app/echo_lot/archive/RunArchive.kt @@ -31,10 +31,23 @@ data class ArchivedRun( val verdict: String? = null, @SerialName("finding_count") val findingCount: Int = 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", /** Whether this run has been accepted by a server, so history can show what is backed up. */ val uploaded: Boolean = false, @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) } /** 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 meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull() ?: return @@ -127,7 +140,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu f, json.encodeToString( 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, savedAtEpochMs = now(), 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, sizeBytes = size, anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full", diff --git a/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt b/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt index 84a672b..9de78e4 100644 --- a/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt +++ b/echolot-app/core-archive/src/test/kotlin/app/echo_lot/archive/RunArchiveTest.kt @@ -25,7 +25,7 @@ class RunArchiveTest { private fun doc(id: String, findings: Int = 1, pad: Int = 0): String { val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" } 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 @@ -120,13 +120,40 @@ class RunArchiveTest { fun uploadStateIsRecorded() { val a = archive() a.save(doc("run-1")) - a.markUploaded("run-1", "fmr") + a.markUploaded("run-1", "fmr", "balanced") val meta = a.list().single() assertTrue(meta.uploaded) assertEquals("fmr", meta.uploadedTo) 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 fun deleteRemovesBothFiles() { val a = archive()