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()