Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1aaa332a |
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "app.echo_lot.app"
|
applicationId = "app.echo_lot.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.0"
|
||||||
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
// 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).
|
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||||
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||||
@@ -48,6 +48,8 @@ dependencies {
|
|||||||
implementation(project(":core-engine"))
|
implementation(project(":core-engine"))
|
||||||
implementation(project(":core-probe"))
|
implementation(project(":core-probe"))
|
||||||
implementation(project(":core-shizuku"))
|
implementation(project(":core-shizuku"))
|
||||||
|
implementation(project(":core-privacy"))
|
||||||
|
implementation(project(":core-archive"))
|
||||||
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import app.echo_lot.measurement.*
|
import app.echo_lot.measurement.*
|
||||||
|
|
||||||
|
/** The app's three top-level screens. */
|
||||||
|
private enum class Screen { RUN, HISTORY, SETTINGS }
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val permissionLauncher =
|
private val permissionLauncher =
|
||||||
@@ -41,13 +50,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
val vm: RunViewModel = viewModel()
|
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:
|
// Automation entry point:
|
||||||
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
// starts a run immediately and uploads the report, so an unattended
|
// starts a run immediately and uploads the report, so an unattended
|
||||||
// measurement needs no UI tapping and no adb round-trip to collect.
|
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||||
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
||||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
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
|
// 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
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
@@ -61,7 +74,42 @@ class MainActivity : ComponentActivity() {
|
|||||||
finish()
|
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,
|
state = vm.state,
|
||||||
onRun = { vm.run() },
|
onRun = { vm.run() },
|
||||||
onCancel = vm::cancel,
|
onCancel = vm::cancel,
|
||||||
@@ -86,8 +134,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
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,
|
onShizukuAction: () -> Unit,
|
||||||
onDeveloperOptions: () -> Unit,
|
onDeveloperOptions: () -> Unit,
|
||||||
onExport: (MeasurementDocument) -> Unit,
|
onExport: (MeasurementDocument) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenHistory: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -135,8 +191,15 @@ private fun EcholotScreen(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
|
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 —
|
// 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.
|
// 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 {
|
state.uploadStatus?.let {
|
||||||
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -338,3 +405,24 @@ private fun Dot(color: Color) {
|
|||||||
private fun SectionTitle(text: String) {
|
private fun SectionTitle(text: String) {
|
||||||
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
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 =
|
fun toJson(doc: MeasurementDocument): String =
|
||||||
json.encodeToString(MeasurementDocument.serializer(), doc)
|
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 dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
|
||||||
val file = File(dir, "echolot-run-${doc.run.id}.json")
|
val file = File(dir, "echolot-run-$runId.json")
|
||||||
file.writeText(toJson(doc))
|
file.writeText(json)
|
||||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||||
return Intent(Intent.ACTION_SEND).apply {
|
return Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "application/json"
|
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 stepsDone: Int = 0,
|
||||||
val stepsTotal: Int = 0,
|
val stepsTotal: Int = 0,
|
||||||
val etaSeconds: 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). */
|
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
|
||||||
val shizukuNotice: String? = null,
|
val shizukuNotice: String? = null,
|
||||||
val shizukuReady: Boolean = false,
|
val shizukuReady: Boolean = false,
|
||||||
@@ -56,6 +60,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
var state by mutableStateOf(UiState())
|
var state by mutableStateOf(UiState())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val settings = Settings(app)
|
||||||
|
private val store = RunStore(app, settings)
|
||||||
|
|
||||||
private var runJob: kotlinx.coroutines.Job? = null
|
private var runJob: kotlinx.coroutines.Job? = null
|
||||||
private val stopShizukuObserver: () -> Unit
|
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
|
* Runs one measurement, then archives it and — if the user has turned that on — uploads it.
|
||||||
* collection endpoint so an unattended run can be retrieved without adb.
|
*
|
||||||
|
* [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
|
if (state.running) return
|
||||||
collected.clear()
|
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 {
|
runJob = viewModelScope.launch {
|
||||||
val doc = withContext(Dispatchers.IO) { measure() }
|
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
|
var status: String? = null
|
||||||
if (upload) {
|
if (devUpload) {
|
||||||
state = state.copy(currentStep = "uploading report")
|
state = state.copy(currentStep = "uploading report")
|
||||||
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||||
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
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(
|
state = UiState(
|
||||||
running = false, currentStep = null, document = doc,
|
running = false, currentStep = null, document = doc,
|
||||||
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
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 {
|
dependencies {
|
||||||
implementation(project(":core-protocol"))
|
implementation(project(":core-protocol"))
|
||||||
implementation(project(":core-measurement"))
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(project(":core-privacy"))
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,19 +54,35 @@ class LiveGrantedTest {
|
|||||||
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
||||||
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
|
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 sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
|
||||||
val bsResp = control.action(
|
val dfResp = control.action(
|
||||||
cred, session.sessionId,
|
cred, session.sessionId,
|
||||||
"""{"action":"big_send","sizes_bytes":${sizes}}""",
|
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
|
||||||
)
|
)
|
||||||
println("big_send accepted: ${bsResp.take(160)}")
|
println("big_send(df) accepted: ${dfResp.take(200)}")
|
||||||
val big = ps.collectGranted(windowMs = 4000)
|
val dfArrived = ps.collectGranted(windowMs = 4000)
|
||||||
.filter { it.type == Wire.TYPE_BIG_SEND }
|
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
|
||||||
val arrived = big.map { it.sizeBytes }.sorted()
|
println("big_send(df) arrived: $dfArrived")
|
||||||
println("big_send arrived sizes: $arrived (requested $sizes)")
|
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
|
||||||
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
|
val pathMtu = dfArrived.max()
|
||||||
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
|
|
||||||
|
// --- 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)
|
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 | ||||||