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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user