server: send granted traffic from the address the session actually used
fmr binds two IPv4 addresses. connFor picked whichever socket of the right family came first in the bind list, so a downtrain for a session established on .150 went out from .151 — and every packet was dropped by the client's NAT, which has no mapping for that pair. tcpdump on the server showed all 50 leaving; the client saw none. Read as "100% downstream loss", which is the worst kind of wrong: a confident measurement of something that never happened. Sessions now record which of our own bound addresses received their traffic, and granted sends (and delayed echo) go back out through that socket. The fallback to a family match is kept for the case where nothing has been received yet, and the test pins both paths — a single-homed lab can never reproduce this. Also: the client-side halves of the same work — anonymizer (core-privacy), local run archive with retention (core-archive), upload client, and the app's settings and history screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7a94c9a3d7
commit
ce1aaa332a
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "app.echo_lot.app"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
||||
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||
@@ -48,6 +48,8 @@ dependencies {
|
||||
implementation(project(":core-engine"))
|
||||
implementation(project(":core-probe"))
|
||||
implementation(project(":core-shizuku"))
|
||||
implementation(project(":core-privacy"))
|
||||
implementation(project(":core-archive"))
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.echo_lot.archive.ArchivedRun
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Archived runs, newest first.
|
||||
*
|
||||
* Each row states plainly whether the run left the device, because "is this backed up / did I
|
||||
* share this?" is the question a history list actually gets asked.
|
||||
*/
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
runs: List<ArchivedRun>,
|
||||
status: String?,
|
||||
onOpen: (String) -> Unit,
|
||||
onUpload: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onBack) { Text("‹ Back") }
|
||||
Text("History", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
if (runs.isEmpty()) {
|
||||
Text(
|
||||
"No archived runs yet. Finished runs are kept here automatically unless you turn " +
|
||||
"archiving off in settings.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(runs, key = { it.id }) { r ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
r.verdict?.uppercase() ?: "—",
|
||||
color = verdictTint(r.verdict),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
" " + humanTime(r.savedAtEpochMs),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}"
|
||||
else "on this device only",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = { onOpen(r.id) }) { Text("Export") }
|
||||
TextButton(onClick = { onUpload(r.id) }) {
|
||||
Text(if (r.uploaded) "Upload again" else "Upload")
|
||||
}
|
||||
TextButton(onClick = { onDelete(r.id) }) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verdictTint(v: String?): Color = when (v?.lowercase()) {
|
||||
"green", "ok", "pass" -> Color(0xFF7FD17F)
|
||||
"yellow", "warn" -> Color(0xFFE0C060)
|
||||
"red", "fail" -> Color(0xFFE07070)
|
||||
else -> Color(0xFFBBBBBB)
|
||||
}
|
||||
|
||||
private val stamp: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
|
||||
|
||||
private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs))
|
||||
@@ -18,6 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -26,9 +30,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.echo_lot.measurement.*
|
||||
|
||||
/** The app's three top-level screens. */
|
||||
private enum class Screen { RUN, HISTORY, SETTINGS }
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val permissionLauncher =
|
||||
@@ -41,13 +50,17 @@ class MainActivity : ComponentActivity() {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
val vm: RunViewModel = viewModel()
|
||||
// Three flat screens, so a plain state variable beats a navigation library:
|
||||
// there is no back stack to model beyond "return to the run screen".
|
||||
var screen by remember { mutableStateOf(Screen.RUN) }
|
||||
var preview by remember { mutableStateOf<String?>(null) }
|
||||
// Automation entry point:
|
||||
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||
// starts a run immediately and uploads the report, so an unattended
|
||||
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||
if (autorun) vm.run(upload = true)
|
||||
if (autorun) vm.run(devUpload = true)
|
||||
}
|
||||
// In autorun the app is a batch job: once the run is done AND the upload
|
||||
// succeeded, show the result briefly, then close so the device is left as it
|
||||
@@ -61,7 +74,42 @@ class MainActivity : ComponentActivity() {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
EcholotScreen(
|
||||
when (screen) {
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
settings = vm.settings,
|
||||
archivedRuns = vm.state.history.size,
|
||||
archivedBytes = vm.archivedBytes(),
|
||||
onApplyRetention = vm::applyRetention,
|
||||
onDeleteAll = vm::deleteAllRuns,
|
||||
onPreviewUpload = {
|
||||
// Preview the newest run, since that is the one the user just made
|
||||
// and the one they are deciding about.
|
||||
vm.state.history.firstOrNull()?.let { r ->
|
||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
||||
}
|
||||
},
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.HISTORY -> HistoryScreen(
|
||||
runs = vm.state.history,
|
||||
status = vm.state.archiveStatus,
|
||||
onOpen = { id ->
|
||||
lifecycleScope.launch {
|
||||
vm.readRun(id)?.let { text ->
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
Report.shareJson(this@MainActivity, id, text),
|
||||
"Export Echolot run",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpload = vm::uploadRun,
|
||||
onDelete = vm::deleteRun,
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.RUN -> EcholotScreen(
|
||||
state = vm.state,
|
||||
onRun = { vm.run() },
|
||||
onCancel = vm::cancel,
|
||||
@@ -86,7 +134,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
},
|
||||
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||
)
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
|
||||
)
|
||||
}
|
||||
preview?.let { text ->
|
||||
UploadPreviewDialog(text) { preview = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +177,8 @@ private fun EcholotScreen(
|
||||
onShizukuAction: () -> Unit,
|
||||
onDeveloperOptions: () -> Unit,
|
||||
onExport: (MeasurementDocument) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenHistory: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -135,8 +191,15 @@ private fun EcholotScreen(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("measure, don't guess",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
|
||||
}
|
||||
TextButton(onClick = onOpenHistory) { Text("History") }
|
||||
TextButton(onClick = onOpenSettings) { Text("Settings") }
|
||||
}
|
||||
|
||||
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
|
||||
// only users who actually use it get reminded that it must be running.
|
||||
@@ -185,6 +248,10 @@ private fun EcholotScreen(
|
||||
}
|
||||
}
|
||||
|
||||
state.archiveStatus?.let {
|
||||
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
state.uploadStatus?.let {
|
||||
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -338,3 +405,24 @@ private fun Dot(color: Color) {
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the exact JSON an upload would send.
|
||||
*
|
||||
* This exists because an anonymizer the user cannot inspect is just a promise. Being able to
|
||||
* read the outgoing document — and find their own SSID absent from it — is what makes the
|
||||
* privacy setting checkable rather than merely stated.
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
title = { Text("This is what would be uploaded") },
|
||||
text = {
|
||||
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
|
||||
Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,10 +17,18 @@ object Report {
|
||||
fun toJson(doc: MeasurementDocument): String =
|
||||
json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||
|
||||
fun share(ctx: Context, doc: MeasurementDocument): Intent {
|
||||
fun share(ctx: Context, doc: MeasurementDocument): Intent =
|
||||
shareJson(ctx, doc.run.id, toJson(doc))
|
||||
|
||||
/**
|
||||
* Shares an already-serialized run — an archived one, whose bytes must go out exactly as
|
||||
* stored rather than being re-serialized through the model (which would silently drop
|
||||
* anything a newer schema version added).
|
||||
*/
|
||||
fun shareJson(ctx: Context, runId: String, json: String): Intent {
|
||||
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
|
||||
val file = File(dir, "echolot-run-${doc.run.id}.json")
|
||||
file.writeText(toJson(doc))
|
||||
val file = File(dir, "echolot-run-$runId.json")
|
||||
file.writeText(json)
|
||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||
return Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/json"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.archive.ArchivedRun
|
||||
import app.echo_lot.archive.RunArchive
|
||||
import app.echo_lot.measurement.MeasurementDocument
|
||||
import app.echo_lot.privacy.Anonymizer
|
||||
import app.echo_lot.privacy.PrivacyLevel
|
||||
import app.echo_lot.privacy.Salt
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.UploadRefused
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Ties together the three things that happen to a finished run: it gets archived, it may get
|
||||
* anonymized, and it may get uploaded — in that order, and with the archive always holding the
|
||||
* *unredacted* document.
|
||||
*
|
||||
* That ordering is the important decision. The local archive is the user's own data on their own
|
||||
* device, and redacting it would destroy exactly the detail that makes a week-old run worth
|
||||
* keeping; the anonymizer exists for the moment data crosses to someone else's machine. So
|
||||
* redaction happens on the way out, per upload, and the archive is never the lossy copy.
|
||||
*/
|
||||
class RunStore(context: Context, private val settings: Settings) {
|
||||
|
||||
private val archive = RunArchive(File(context.filesDir, "runs"))
|
||||
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||
|
||||
fun list(): List<ArchivedRun> = archive.list()
|
||||
fun read(id: String): String? = archive.read(id)
|
||||
fun delete(id: String) = archive.delete(id)
|
||||
fun deleteAll(): Int = archive.deleteAll()
|
||||
fun totalBytes(): Long = archive.totalBytes()
|
||||
|
||||
/** Archives a finished run under the user's retention policy. Null when archiving is off. */
|
||||
fun archive(doc: MeasurementDocument): ArchivedRun? =
|
||||
archive.save(Report.toJson(doc), settings.retention())
|
||||
|
||||
/** Applies retention now — e.g. after the user tightens the limits in settings. */
|
||||
fun purgeNow() = archive.purge(settings.retention())
|
||||
|
||||
/**
|
||||
* Produces exactly the bytes an upload would send, so the UI can show the user their own
|
||||
* document as the server will see it *before* it goes. "Preview what you're about to share"
|
||||
* is the only honest way to present an anonymizer: its correctness is not something a user
|
||||
* should have to take on faith.
|
||||
*/
|
||||
fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String {
|
||||
val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull()
|
||||
?: return docJson
|
||||
return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed))
|
||||
}
|
||||
|
||||
private fun salt(): Salt =
|
||||
if (settings.stableSalt) Salt.stable(settings.saltSecret())
|
||||
else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) })
|
||||
|
||||
sealed interface UploadOutcome {
|
||||
data class Sent(val serverName: String, val detail: String) : UploadOutcome
|
||||
/** The operator's policy says no. Not retryable, and not the user's fault. */
|
||||
data class Refused(val reason: String) : UploadOutcome
|
||||
data class Failed(val detail: String) : UploadOutcome
|
||||
data object NotConfigured : UploadOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one archived run to the configured server, redacting first.
|
||||
*
|
||||
* The server's advertised minimum wins over the user's preference when it is stricter — a
|
||||
* server may demand more anonymization than the user chose, never less. Blocking; callers
|
||||
* run it off the main thread.
|
||||
*/
|
||||
fun upload(runId: String): UploadOutcome {
|
||||
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
|
||||
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
|
||||
return try {
|
||||
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin))
|
||||
val profile = client.profile(settings.serverCredential)
|
||||
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
|
||||
|
||||
val level = PrivacyLevel.max(
|
||||
settings.privacyLevel,
|
||||
PrivacyLevel.fromWire(profile.uploads.minAnonymization),
|
||||
)
|
||||
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)}")
|
||||
} catch (e: UploadRefused) {
|
||||
UploadOutcome.Refused(e.message ?: "refused by the server")
|
||||
} catch (t: Throwable) {
|
||||
UploadOutcome.Failed(t.message ?: t.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ data class UiState(
|
||||
val stepsDone: Int = 0,
|
||||
val stepsTotal: Int = 0,
|
||||
val etaSeconds: Int = 0,
|
||||
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
||||
val archiveStatus: String? = null,
|
||||
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
||||
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
|
||||
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
|
||||
val shizukuNotice: String? = null,
|
||||
val shizukuReady: Boolean = false,
|
||||
@@ -56,6 +60,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
var state by mutableStateOf(UiState())
|
||||
private set
|
||||
|
||||
val settings = Settings(app)
|
||||
private val store = RunStore(app, settings)
|
||||
|
||||
private var runJob: kotlinx.coroutines.Job? = null
|
||||
private val stopShizukuObserver: () -> Unit
|
||||
|
||||
@@ -92,22 +99,111 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the
|
||||
* collection endpoint so an unattended run can be retrieved without adb.
|
||||
* Runs one measurement, then archives it and — if the user has turned that on — uploads it.
|
||||
*
|
||||
* [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart
|
||||
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
||||
* silently satisfy, the consent-gated one.
|
||||
*/
|
||||
fun run(upload: Boolean = false) {
|
||||
fun run(devUpload: Boolean = false) {
|
||||
if (state.running) return
|
||||
collected.clear()
|
||||
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
|
||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||
uploadStatus = null, archiveStatus = null)
|
||||
runJob = viewModelScope.launch {
|
||||
val doc = withContext(Dispatchers.IO) { measure() }
|
||||
|
||||
step("archiving")
|
||||
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
||||
var archiveStatus = if (archived != null) {
|
||||
"archived locally (${store.list().size} runs kept)"
|
||||
} else {
|
||||
"not archived — archiving is off in settings"
|
||||
}
|
||||
|
||||
var status: String? = null
|
||||
if (upload) {
|
||||
if (devUpload) {
|
||||
state = state.copy(currentStep = "uploading report")
|
||||
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
||||
}
|
||||
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status)
|
||||
if (archived != null && settings.autoUpload) {
|
||||
state = state.copy(currentStep = "uploading to server")
|
||||
val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) }
|
||||
archiveStatus += " · " + describe(outcome)
|
||||
}
|
||||
|
||||
state = UiState(
|
||||
running = false, currentStep = null, document = doc,
|
||||
uploadStatus = status, archiveStatus = archiveStatus,
|
||||
history = withContext(Dispatchers.IO) { store.list() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
|
||||
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
|
||||
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
|
||||
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
|
||||
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
|
||||
}
|
||||
|
||||
// ---- history ---------------------------------------------------------------------
|
||||
|
||||
fun refreshHistory() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRun(id: String) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { store.delete(id) }
|
||||
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllRuns() {
|
||||
viewModelScope.launch {
|
||||
val n = withContext(Dispatchers.IO) { store.deleteAll() }
|
||||
state = state.copy(
|
||||
history = emptyList(),
|
||||
archiveStatus = "deleted $n archived run(s)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads one already-archived run on demand, regardless of the auto-upload setting. */
|
||||
fun uploadRun(id: String) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(archiveStatus = "uploading …")
|
||||
val outcome = withContext(Dispatchers.IO) { store.upload(id) }
|
||||
state = state.copy(
|
||||
archiveStatus = describe(outcome),
|
||||
history = withContext(Dispatchers.IO) { store.list() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The archived document as stored, for export. */
|
||||
suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) }
|
||||
|
||||
/** The exact bytes an upload would send, for the settings screen's preview. */
|
||||
suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) {
|
||||
store.read(id)?.let { store.redactedForUpload(it) }
|
||||
}
|
||||
|
||||
fun archivedBytes(): Long = store.totalBytes()
|
||||
|
||||
/** Re-applies retention after the user changes the limits. */
|
||||
fun applyRetention() {
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { store.purgeNow() }
|
||||
state = state.copy(
|
||||
history = withContext(Dispatchers.IO) { store.list() },
|
||||
archiveStatus = if (result.isEmpty) "nothing to purge"
|
||||
else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +218,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
state = UiState(
|
||||
running = false, currentStep = null, document = doc,
|
||||
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||
archiveStatus = "partial run — not archived",
|
||||
history = state.history,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import app.echo_lot.archive.RetentionPolicy
|
||||
import app.echo_lot.privacy.PrivacyLevel
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* User settings for archiving, uploading and anonymization.
|
||||
*
|
||||
* Defaults are the conservative reading of "an engineer's tool that still respects the person
|
||||
* holding it": keep history (that is the point of the archive), never upload without being asked,
|
||||
* and when uploading, strip identifiers unless the user says this is their own server.
|
||||
*
|
||||
* SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at
|
||||
* the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing.
|
||||
*/
|
||||
class Settings(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
||||
|
||||
// ---- archive ---------------------------------------------------------------------
|
||||
|
||||
var archiveEnabled: Boolean
|
||||
get() = prefs.getBoolean(ARCHIVE_ENABLED, true)
|
||||
set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply()
|
||||
|
||||
/** 0 = no ceiling. */
|
||||
var maxRuns: Int
|
||||
get() = prefs.getInt(MAX_RUNS, 100)
|
||||
set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply()
|
||||
|
||||
var maxAgeDays: Int
|
||||
get() = prefs.getInt(MAX_AGE_DAYS, 90)
|
||||
set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply()
|
||||
|
||||
var maxTotalMb: Int
|
||||
get() = prefs.getInt(MAX_TOTAL_MB, 64)
|
||||
set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply()
|
||||
|
||||
fun retention(): RetentionPolicy = RetentionPolicy(
|
||||
enabled = archiveEnabled,
|
||||
maxRuns = maxRuns,
|
||||
maxAgeDays = maxAgeDays,
|
||||
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
||||
)
|
||||
|
||||
// ---- upload ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Off by default. Measurement data describes the network the user is standing in; sending it
|
||||
* anywhere is a decision they make, not one they discover after the fact.
|
||||
*/
|
||||
var autoUpload: Boolean
|
||||
get() = prefs.getBoolean(AUTO_UPLOAD, false)
|
||||
set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply()
|
||||
|
||||
/** Anonymization applied before a run leaves the device. Never applied to the local archive. */
|
||||
var privacyLevel: PrivacyLevel
|
||||
get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire))
|
||||
set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply()
|
||||
|
||||
/**
|
||||
* Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as
|
||||
* last week") and is what someone wants on their own server — but it also produces an
|
||||
* identifier that links a device's uploads, so it is off unless chosen.
|
||||
*/
|
||||
var stableSalt: Boolean
|
||||
get() = prefs.getBoolean(STABLE_SALT, false)
|
||||
set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply()
|
||||
|
||||
/**
|
||||
* The device-local secret behind stable pseudonyms. Generated once, never leaves the device,
|
||||
* and clearing it (via [resetSalt]) breaks the link to everything uploaded before.
|
||||
*/
|
||||
fun saltSecret(): ByteArray {
|
||||
prefs.getString(SALT_SECRET, null)?.let { return hex(it) }
|
||||
val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply()
|
||||
return fresh
|
||||
}
|
||||
|
||||
fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply()
|
||||
|
||||
// ---- server ----------------------------------------------------------------------
|
||||
|
||||
var serverUrl: String
|
||||
get() = prefs.getString(SERVER_URL, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply()
|
||||
|
||||
var serverPin: String
|
||||
get() = prefs.getString(SERVER_PIN, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
|
||||
|
||||
var serverCredential: String
|
||||
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
||||
|
||||
val serverConfigured: Boolean
|
||||
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
|
||||
|
||||
private fun hex(s: String) = ByteArray(s.length / 2) {
|
||||
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ARCHIVE_ENABLED = "archive_enabled"
|
||||
const val MAX_RUNS = "archive_max_runs"
|
||||
const val MAX_AGE_DAYS = "archive_max_age_days"
|
||||
const val MAX_TOTAL_MB = "archive_max_total_mb"
|
||||
const val AUTO_UPLOAD = "auto_upload"
|
||||
const val PRIVACY_LEVEL = "privacy_level"
|
||||
const val STABLE_SALT = "stable_salt"
|
||||
const val SALT_SECRET = "salt_secret"
|
||||
const val SERVER_URL = "server_url"
|
||||
const val SERVER_PIN = "server_pin"
|
||||
const val SERVER_CRED = "server_credential"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.echo_lot.privacy.PrivacyLevel
|
||||
|
||||
/**
|
||||
* Archiving, upload and anonymization settings.
|
||||
*
|
||||
* The screen is written to make the consequences legible rather than to look tidy: every toggle
|
||||
* says what it means for the user's data in a sentence, and the privacy levels are described by
|
||||
* what survives them, because "balanced" on its own tells nobody anything.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: Settings,
|
||||
archivedRuns: Int,
|
||||
archivedBytes: Long,
|
||||
onApplyRetention: () -> Unit,
|
||||
onDeleteAll: () -> Unit,
|
||||
onPreviewUpload: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||
// through on change. A dozen scalars; a store with flows would be ceremony for nothing.
|
||||
var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) }
|
||||
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
||||
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
||||
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
||||
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
|
||||
var serverPin by remember { mutableStateOf(settings.serverPin) }
|
||||
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onBack) { Text("‹ Back") }
|
||||
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// ---- archive ----
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Archive", style = MaterialTheme.typography.titleMedium)
|
||||
Toggle(
|
||||
label = "Keep finished runs on this device",
|
||||
detail = "History is what makes a run comparable later. Archived runs are " +
|
||||
"stored complete and unredacted — anonymization only applies to uploads.",
|
||||
checked = archiveEnabled,
|
||||
) { archiveEnabled = it; settings.archiveEnabled = it }
|
||||
|
||||
Text(
|
||||
"Purge automatically when a run exceeds any of these. 0 turns that limit off.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
NumberField("Keep at most (runs)", maxRuns) {
|
||||
maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0
|
||||
}
|
||||
NumberField("Delete older than (days)", maxAgeDays) {
|
||||
maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0
|
||||
}
|
||||
NumberField("Keep at most (MB)", maxTotalMb) {
|
||||
maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0
|
||||
}
|
||||
Text(
|
||||
"$archivedRuns run(s), ${archivedBytes / 1024} kB stored",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onApplyRetention) { Text("Apply now") }
|
||||
TextButton(onClick = onDeleteAll) { Text("Delete all runs") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- privacy ----
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("What leaves the device", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Applied to uploads only. Measurements, verdicts and finding codes survive " +
|
||||
"every level — only the parts that identify you or your network change.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (level in PrivacyLevel.entries) {
|
||||
FilterChip(
|
||||
selected = privacy == level,
|
||||
onClick = { privacy = level; settings.privacyLevel = level },
|
||||
label = { Text(level.wire) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
Toggle(
|
||||
label = "Stable pseudonyms across runs",
|
||||
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
|
||||
"each time). It also links your uploads together, so leave it off on a " +
|
||||
"server you don't run yourself.",
|
||||
checked = stableSalt,
|
||||
) { stableSalt = it; settings.stableSalt = it }
|
||||
|
||||
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- upload ----
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Upload", style = MaterialTheme.typography.titleMedium)
|
||||
Toggle(
|
||||
label = "Upload finished runs automatically",
|
||||
detail = "Sends each completed run to the server below, anonymized to the " +
|
||||
"level above. The server may require more anonymization than you chose; " +
|
||||
"it can never require less.",
|
||||
checked = autoUpload,
|
||||
) { autoUpload = it; settings.autoUpload = it }
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
|
||||
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
|
||||
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = serverCred,
|
||||
onValueChange = { serverCred = it; settings.serverCredential = it },
|
||||
label = { Text("Device credential") }, singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
if (settings.serverConfigured) "Server configured."
|
||||
else "Uploads stay off until all three fields are set.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
|
||||
PrivacyLevel.FULL ->
|
||||
"Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " +
|
||||
"as measured. Appropriate for a server you run yourself."
|
||||
PrivacyLevel.BALANCED ->
|
||||
"Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " +
|
||||
"prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " +
|
||||
"ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " +
|
||||
"192.168.1.1 describes the topology and not the person."
|
||||
PrivacyLevel.STRICT ->
|
||||
"Only numbers: test results, metrics and finding codes. No network description, no raw " +
|
||||
"evidence, no finding text. Nothing left can identify a network."
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberField(label: String, value: String, onChange: (String) -> Unit) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// The on-device run archive: measurement documents on disk, with retention.
|
||||
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
|
||||
// — the part with edge cases — are unit-testable without a device.
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||
}
|
||||
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
tasks.test { useJUnitPlatform() }
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package archive keeps completed measurement runs on the device.
|
||||
//
|
||||
// The point of an archive is the second run: "this network was fine on Tuesday" is only
|
||||
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
|
||||
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
|
||||
// job somebody remembers to write — every save enforces it.
|
||||
//
|
||||
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
|
||||
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
|
||||
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
|
||||
// write never leaves a half-document that reads as real.
|
||||
package app.echo_lot.archive
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
/** Index entry for one archived run — enough for a history list without opening the documents. */
|
||||
@Serializable
|
||||
data class ArchivedRun(
|
||||
val id: String,
|
||||
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
val verdict: String? = null,
|
||||
@SerialName("finding_count") val findingCount: Int = 0,
|
||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
|
||||
* them. Zero disables that limit.
|
||||
*
|
||||
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
|
||||
* a pattern ("it degrades every evening") without turning the phone into an archive of every
|
||||
* network its owner ever walked past.
|
||||
*/
|
||||
@Serializable
|
||||
data class RetentionPolicy(
|
||||
/**
|
||||
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
|
||||
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
|
||||
* a user who turns all the caps off ends up with an empty history.
|
||||
*/
|
||||
val enabled: Boolean = true,
|
||||
@SerialName("max_runs") val maxRuns: Int = 100,
|
||||
@SerialName("max_age_days") val maxAgeDays: Int = 90,
|
||||
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
|
||||
) {
|
||||
companion object {
|
||||
/** Archiving off: runs are shown once and never written. */
|
||||
val KeepNothing = RetentionPolicy(enabled = false)
|
||||
|
||||
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
|
||||
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
|
||||
|
||||
val Default = RetentionPolicy()
|
||||
}
|
||||
|
||||
val keepsAnything: Boolean get() = enabled
|
||||
}
|
||||
|
||||
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
|
||||
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
|
||||
val isEmpty: Boolean get() = removed.isEmpty()
|
||||
}
|
||||
|
||||
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
init {
|
||||
dir.mkdirs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one run and applies retention. Returns the index entry, or null when the policy
|
||||
* keeps nothing at all — in which case nothing is written, rather than written and instantly
|
||||
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
|
||||
*/
|
||||
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
|
||||
if (!policy.keepsAnything) return null
|
||||
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
|
||||
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
|
||||
|
||||
writeAtomically(File(dir, meta.id + EXT), runJson)
|
||||
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
|
||||
purge(policy)
|
||||
return meta
|
||||
}
|
||||
|
||||
/** History, newest first. */
|
||||
fun list(): List<ArchivedRun> =
|
||||
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
|
||||
.mapNotNull { f ->
|
||||
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
|
||||
}
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
|
||||
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
|
||||
|
||||
fun delete(id: String): Boolean {
|
||||
val s = safe(id)
|
||||
val doc = File(dir, s + EXT).delete()
|
||||
File(dir, s + META_EXT).delete()
|
||||
return doc
|
||||
}
|
||||
|
||||
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) {
|
||||
val f = File(dir, safe(id) + META_EXT)
|
||||
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
|
||||
?: return
|
||||
writeAtomically(
|
||||
f,
|
||||
json.encodeToString(
|
||||
ArchivedRun.serializer(),
|
||||
meta.copy(uploaded = true, uploadedTo = serverName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
|
||||
|
||||
/**
|
||||
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
|
||||
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
|
||||
* should not be pre-empted by a size sweep deleting last week instead.
|
||||
*/
|
||||
fun purge(policy: RetentionPolicy): PurgeResult {
|
||||
val removed = ArrayList<String>()
|
||||
var freed = 0L
|
||||
fun drop(r: ArchivedRun) {
|
||||
if (delete(r.id)) {
|
||||
removed.add(r.id)
|
||||
freed += r.sizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
var kept = list()
|
||||
if (policy.maxAgeDays > 0) {
|
||||
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
|
||||
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
|
||||
stale.forEach(::drop)
|
||||
kept = fresh
|
||||
}
|
||||
if (policy.maxTotalBytes > 0) {
|
||||
var total = kept.sumOf { it.sizeBytes }
|
||||
// Oldest first until we are under the ceiling.
|
||||
for (r in kept.reversed()) {
|
||||
if (total <= policy.maxTotalBytes) break
|
||||
drop(r)
|
||||
total -= r.sizeBytes
|
||||
}
|
||||
kept = kept.filter { it.id !in removed }
|
||||
}
|
||||
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
|
||||
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
|
||||
}
|
||||
return PurgeResult(removed, freed)
|
||||
}
|
||||
|
||||
// ---- internals ---------------------------------------------------------------------
|
||||
|
||||
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
|
||||
val run = doc["run"]?.jsonObject ?: return null
|
||||
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
|
||||
return ArchivedRun(
|
||||
id = id,
|
||||
savedAtEpochMs = now(),
|
||||
startedAt = run["started_at"]?.jsonPrimitive?.content,
|
||||
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
|
||||
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
|
||||
sizeBytes = size,
|
||||
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeAtomically(target: File, content: String) {
|
||||
val tmp = File(target.parentFile, target.name + ".tmp")
|
||||
tmp.writeText(content)
|
||||
if (!tmp.renameTo(target)) {
|
||||
target.delete()
|
||||
tmp.renameTo(target)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
|
||||
private fun safe(id: String): String = buildString {
|
||||
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
|
||||
}.take(64)
|
||||
|
||||
private companion object {
|
||||
const val EXT = ".json"
|
||||
const val META_EXT = ".meta.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.archive
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RunArchiveTest {
|
||||
|
||||
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
|
||||
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
|
||||
|
||||
private fun archive() = RunArchive(dir) { clock }
|
||||
|
||||
@AfterTest fun cleanup() { dir.deleteRecursively() }
|
||||
|
||||
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)}"}"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savedRunsComeBackNewestFirst() {
|
||||
val a = archive()
|
||||
for (i in 1..3) {
|
||||
a.save(doc("run-$i"))
|
||||
clock += 60_000
|
||||
}
|
||||
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theIndexSummarisesTheDocument() {
|
||||
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
|
||||
assertEquals("warn", meta.verdict)
|
||||
assertEquals(4, meta.findingCount)
|
||||
assertEquals("balanced", meta.anonymization)
|
||||
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
|
||||
assertFalse(meta.uploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDocumentComesBackByteForByte() {
|
||||
val a = archive()
|
||||
val original = doc("run-1")
|
||||
a.save(original)
|
||||
assertEquals(original, a.read("run-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countLimitKeepsTheNewest() {
|
||||
val a = archive()
|
||||
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
|
||||
for (i in 1..7) {
|
||||
a.save(doc("run-$i"), policy)
|
||||
clock += 60_000
|
||||
}
|
||||
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
|
||||
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ageLimitDropsRunsPastTheWindow() {
|
||||
val a = archive()
|
||||
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
|
||||
a.save(doc("old"), policy)
|
||||
clock += 30L * 24 * 60 * 60 * 1000 // a month later
|
||||
a.save(doc("new"), policy)
|
||||
assertEquals(listOf("new"), a.list().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
|
||||
val a = archive()
|
||||
val one = doc("x", pad = 900).toByteArray().size.toLong()
|
||||
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
|
||||
for (i in 1..5) {
|
||||
a.save(doc("run-$i", pad = 900), policy)
|
||||
clock += 60_000
|
||||
}
|
||||
val kept = a.list()
|
||||
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
|
||||
assertEquals("run-5", kept.first().id, "the newest run must always survive")
|
||||
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
|
||||
}
|
||||
|
||||
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
|
||||
@Test
|
||||
fun keepNothingWritesNothing() {
|
||||
val a = archive()
|
||||
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
|
||||
assertTrue(a.list().isEmpty())
|
||||
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
|
||||
}
|
||||
|
||||
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
|
||||
// one: turning every limit off should keep everything, not wipe the history.
|
||||
@Test
|
||||
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
|
||||
val a = archive()
|
||||
for (i in 1..5) {
|
||||
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
|
||||
clock += 60_000
|
||||
}
|
||||
assertEquals(5, a.list().size)
|
||||
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
|
||||
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadStateIsRecorded() {
|
||||
val a = archive()
|
||||
a.save(doc("run-1"))
|
||||
a.markUploaded("run-1", "fmr")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesBothFiles() {
|
||||
val a = archive()
|
||||
a.save(doc("run-1"))
|
||||
assertTrue(a.delete("run-1"))
|
||||
assertTrue(a.list().isEmpty())
|
||||
assertNull(a.read("run-1"))
|
||||
assertEquals(0, dir.listFiles()?.size ?: 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedInputIsRejectedRatherThanArchived() {
|
||||
val a = archive()
|
||||
assertNull(a.save("not json"))
|
||||
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
|
||||
assertTrue(a.list().isEmpty())
|
||||
}
|
||||
|
||||
// Run ids come from a document that may have been produced elsewhere; they must not be able
|
||||
// to write outside the archive directory.
|
||||
@Test
|
||||
fun runIdsCannotEscapeTheArchiveDirectory() {
|
||||
val a = archive()
|
||||
a.save(doc("../../evil"))
|
||||
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
|
||||
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun purgeReportsWhatItRemoved() {
|
||||
val a = archive()
|
||||
for (i in 1..5) {
|
||||
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
|
||||
clock += 60_000
|
||||
}
|
||||
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
|
||||
assertEquals(3, result.removed.size)
|
||||
assertTrue(result.freedBytes > 0)
|
||||
assertEquals(2, a.list().size)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ plugins {
|
||||
dependencies {
|
||||
implementation(project(":core-protocol"))
|
||||
implementation(project(":core-measurement"))
|
||||
implementation(project(":core-privacy"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
@@ -54,19 +54,35 @@ class LiveGrantedTest {
|
||||
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
||||
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
|
||||
|
||||
// --- big_send: which downstream sizes survive? ---
|
||||
// --- big_send with DF: the largest size that arrives is the downstream path MTU ---
|
||||
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
|
||||
val bsResp = control.action(
|
||||
val dfResp = control.action(
|
||||
cred, session.sessionId,
|
||||
"""{"action":"big_send","sizes_bytes":${sizes}}""",
|
||||
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
|
||||
)
|
||||
println("big_send accepted: ${bsResp.take(160)}")
|
||||
val big = ps.collectGranted(windowMs = 4000)
|
||||
.filter { it.type == Wire.TYPE_BIG_SEND }
|
||||
val arrived = big.map { it.sizeBytes }.sorted()
|
||||
println("big_send arrived sizes: $arrived (requested $sizes)")
|
||||
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
|
||||
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
|
||||
println("big_send(df) accepted: ${dfResp.take(200)}")
|
||||
val dfArrived = ps.collectGranted(windowMs = 4000)
|
||||
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
|
||||
println("big_send(df) arrived: $dfArrived")
|
||||
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
|
||||
val pathMtu = dfArrived.max()
|
||||
|
||||
// --- and without DF, to see whether fragments get through above that ---
|
||||
val fragResp = control.action(
|
||||
cred, session.sessionId,
|
||||
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
|
||||
)
|
||||
println("big_send(frag) accepted: ${fragResp.take(200)}")
|
||||
val fragArrived = ps.collectGranted(windowMs = 4000)
|
||||
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
|
||||
println("big_send(frag) arrived: $fragArrived")
|
||||
|
||||
// The distinction the DF flag exists for: fragmented delivery may exceed the
|
||||
// unfragmented path MTU, and reporting the former as the latter would be a lie.
|
||||
println("downstream path MTU (payload bytes) = $pathMtu; " +
|
||||
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
|
||||
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
|
||||
"fragmented delivery should reach at least as far as unfragmented")
|
||||
}
|
||||
control.deleteSession(cred, session.sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.privacy.Anonymizer
|
||||
import app.echo_lot.privacy.PrivacyLevel
|
||||
import app.echo_lot.privacy.Salt
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.UploadRefused
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
|
||||
*
|
||||
* The point is not that the HTTP works — it is that what comes *back off the server* has been
|
||||
* stripped. Uploading and then re-reading the stored document is the only check that proves the
|
||||
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
|
||||
* ECHOLOT_LIVE_*.
|
||||
*/
|
||||
class LiveUploadTest {
|
||||
|
||||
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||
private val json = Json { prettyPrint = false }
|
||||
|
||||
private fun sampleRun(id: String) = """
|
||||
{
|
||||
"schema": "echolot/measurement",
|
||||
"run": {
|
||||
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
|
||||
"notes": "kitchen table",
|
||||
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
|
||||
},
|
||||
"networks": [{
|
||||
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
|
||||
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
|
||||
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
|
||||
}],
|
||||
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
|
||||
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
|
||||
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
|
||||
"summary": {"verdict": "warn"}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun uploadRoundTrip() {
|
||||
if (url == null || pin == null || cred == null) {
|
||||
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||
}
|
||||
val control = ControlClient(url, setOf(pin))
|
||||
val profile = control.profile(cred)
|
||||
val policy = profile.uploads
|
||||
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
|
||||
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
|
||||
|
||||
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
|
||||
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
|
||||
val redacted = json.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
|
||||
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
|
||||
)
|
||||
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
|
||||
|
||||
if (!policy.accepted) {
|
||||
// A server configured to refuse must refuse — that is the behaviour worth asserting.
|
||||
try {
|
||||
control.uploadRun(cred, redacted)
|
||||
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
|
||||
} catch (e: UploadRefused) {
|
||||
println("upload correctly refused: ${e.message?.take(140)}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val created = control.uploadRun(cred, redacted)
|
||||
println("stored: ${created.take(200)}")
|
||||
|
||||
val listed = control.listRuns(cred)
|
||||
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
|
||||
|
||||
val fetched = control.getRun(cred, runId)
|
||||
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
|
||||
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
|
||||
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
|
||||
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
|
||||
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
|
||||
println("round trip verified: identifiers stripped, measurements intact")
|
||||
|
||||
control.deleteRun(cred, runId)
|
||||
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
|
||||
println("deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
|
||||
// dependency-free beyond JSON: it must be trivially auditable, because a bug
|
||||
// here leaks a user's network onto someone else's server.
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||
}
|
||||
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
tasks.test { useJUnitPlatform() }
|
||||
@@ -0,0 +1,237 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package privacy implements the anonymization contract of measurement-schema.md §8.
|
||||
//
|
||||
// The threat model is specific. An engineer running their own server wants the full document —
|
||||
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
|
||||
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
|
||||
// filter: the output is still a valid measurement document with the same tests, metrics and
|
||||
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
|
||||
// still answerable from pseudonyms alone.
|
||||
//
|
||||
// Two properties are load-bearing and are what the tests pin:
|
||||
// - Consistency within a document: one input value always maps to one pseudonym, so
|
||||
// correlations inside a run survive.
|
||||
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
|
||||
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
|
||||
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
|
||||
package app.echo_lot.privacy
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
|
||||
enum class PrivacyLevel(val wire: String) {
|
||||
/** Nothing removed. The right choice for your own server. */
|
||||
FULL("full"),
|
||||
|
||||
/**
|
||||
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
|
||||
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
|
||||
* which MikroTik, on which SSID, next to whose Chromecast.
|
||||
*/
|
||||
BALANCED("balanced"),
|
||||
|
||||
/**
|
||||
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
|
||||
* codes and severities but lose descriptions (which quote real names). What is left cannot
|
||||
* identify a network, and is still enough for aggregate "how common is this fault" work.
|
||||
*/
|
||||
STRICT("strict");
|
||||
|
||||
companion object {
|
||||
fun fromWire(s: String?): PrivacyLevel =
|
||||
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
|
||||
|
||||
/** The stricter of two levels — used to honour a server's minimum. */
|
||||
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
|
||||
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
|
||||
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
|
||||
* own — the app makes that an explicit choice, not a default.
|
||||
*/
|
||||
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
|
||||
companion object {
|
||||
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
|
||||
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a measurement document to [level].
|
||||
*
|
||||
* Field classification is by JSON key name, because the schema names things consistently
|
||||
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
|
||||
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
|
||||
* a known-sensitive container, and left alone otherwise — see [Classification].
|
||||
*/
|
||||
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
||||
|
||||
private val cache = HashMap<String, String>()
|
||||
|
||||
fun anonymize(doc: JsonObject): JsonObject {
|
||||
if (level == PrivacyLevel.FULL) return stamp(doc)
|
||||
val walked = walkObject(doc, path = emptyList())
|
||||
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
|
||||
return stamp(out)
|
||||
}
|
||||
|
||||
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
|
||||
private fun stamp(doc: JsonObject): JsonObject {
|
||||
val run = doc["run"]?.jsonObject ?: return doc
|
||||
val privacy = buildJsonObject {
|
||||
put("anonymization", level.wire)
|
||||
put("salt", if (salt.stable) "stable" else "per_run")
|
||||
}
|
||||
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
|
||||
}
|
||||
|
||||
// ---- the tree walk -------------------------------------------------------------------
|
||||
|
||||
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
|
||||
for ((k, v) in obj) {
|
||||
val childPath = path + k
|
||||
when {
|
||||
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
|
||||
else -> put(k, walk(k, v, childPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
|
||||
is JsonObject -> walkObject(v, path)
|
||||
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
|
||||
is JsonPrimitive ->
|
||||
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
|
||||
else v
|
||||
}
|
||||
|
||||
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||
null -> value
|
||||
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
|
||||
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
||||
LogicalType.IP4 -> ip4(value)
|
||||
LogicalType.IP6 -> ip6(value)
|
||||
LogicalType.FQDN -> fqdn(value)
|
||||
LogicalType.OPAQUE_ID -> "redacted"
|
||||
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
|
||||
}
|
||||
|
||||
// ---- per-type transforms -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
|
||||
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
|
||||
* MikroTik" does not.
|
||||
*/
|
||||
private fun macPreservingOui(value: String): String {
|
||||
val sep = if (value.contains('-')) '-' else ':'
|
||||
val parts = value.split(sep)
|
||||
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
|
||||
val nic = pseudo("mac", value) { it }
|
||||
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
|
||||
.joinToString(sep.toString())
|
||||
.lowercase(Locale.ROOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
|
||||
* CGNAT addresses say something about the topology and nothing about the person, and a run
|
||||
* where 192.168.1.1 became a random public address would be actively misleading to read.
|
||||
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
|
||||
*/
|
||||
private fun ip4(value: String): String {
|
||||
val o = value.split(".")
|
||||
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
|
||||
val n = o.map { it.toInt() }
|
||||
val reserved = n[0] == 10 ||
|
||||
(n[0] == 172 && n[1] in 16..31) ||
|
||||
(n[0] == 192 && n[1] == 168) ||
|
||||
(n[0] == 169 && n[1] == 254) ||
|
||||
(n[0] == 100 && n[1] in 64..127) ||
|
||||
n[0] == 127 || n[0] == 0 || n[0] >= 224
|
||||
if (reserved) return value
|
||||
val h = pseudo("ip4", value) { it }
|
||||
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
|
||||
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
|
||||
* is a device fingerprint, especially with EUI-64.
|
||||
*/
|
||||
private fun ip6(value: String): String {
|
||||
val v = value.lowercase(Locale.ROOT)
|
||||
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
|
||||
val groups = v.substringBefore('%').split(":")
|
||||
if (groups.size < 3) return v
|
||||
val h = pseudo("ip6", value) { it }
|
||||
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
|
||||
* or "…under example.com" survives without naming the host. The suffix list is deliberately
|
||||
* short: guessing wrong keeps *more* pseudonymized, never less.
|
||||
*/
|
||||
private fun fqdn(value: String): String {
|
||||
if (value.isEmpty()) return value
|
||||
val trailing = value.endsWith(".")
|
||||
val labels = value.trimEnd('.').split(".")
|
||||
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
|
||||
val keep = if (labels.last() in publicSuffixes) 1 else 0
|
||||
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
|
||||
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
|
||||
}
|
||||
|
||||
// ---- STRICT ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
|
||||
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
|
||||
* (it interpolates real names), networks go entirely.
|
||||
*/
|
||||
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
|
||||
for ((k, v) in doc) {
|
||||
when (k) {
|
||||
"networks", "server_sessions" -> Unit
|
||||
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
|
||||
val o = t.jsonObject
|
||||
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
|
||||
}))
|
||||
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
|
||||
val o = f.jsonObject
|
||||
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
|
||||
}))
|
||||
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
|
||||
else -> put(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pseudonym machinery ---------------------------------------------------------------
|
||||
|
||||
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
||||
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
||||
cache.getOrPut("$domain | ||||