compat: SemVer version windows between app and server
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- GET /v1/profile is never gated. It is where a refused client learns which
version it needs; gating it leaves the user with a network error instead of
an answer, which is precisely the confusion this exists to remove.
- An unparseable or absent version is "unknown", and is allowed. Development
builds report "dev", and a client too old to send the header cannot be
identified anyway.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -8,6 +8,20 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// The app's version is SemVer and lives here, once. versionCode is derived from it rather than
|
||||
// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number
|
||||
// that a human has to remember to bump is a number that eventually disagrees with the first — and
|
||||
// the version is now load-bearing, since the server decides whether to serve us by it.
|
||||
//
|
||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
||||
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
||||
val appVersionName = "0.2.0"
|
||||
|
||||
fun versionCodeOf(semver: String): Int {
|
||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||
return major * 1_000_000 + minor * 10_000 + patch * 10
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.echo_lot.app"
|
||||
compileSdk = 36
|
||||
@@ -16,12 +30,15 @@ android {
|
||||
applicationId = "app.echo_lot.app"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = versionCodeOf(appVersionName)
|
||||
versionName = appVersionName
|
||||
// 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\"")
|
||||
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||
// The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's
|
||||
// parser anyway — sent to servers so they can apply their compatibility window.
|
||||
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
|
||||
}
|
||||
buildTypes {
|
||||
release { isMinifyEnabled = false }
|
||||
|
||||
@@ -88,6 +88,8 @@ class MainActivity : ComponentActivity() {
|
||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
||||
}
|
||||
},
|
||||
onCheckServer = vm::checkServer,
|
||||
serverStatus = vm.state.archiveStatus,
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.HISTORY -> HistoryScreen(
|
||||
|
||||
@@ -10,8 +10,10 @@ 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.Compat
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.UploadRefused
|
||||
import app.echo_lot.protocol.VersionRefused
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -66,10 +68,46 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
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
|
||||
/**
|
||||
* The two builds do not go together. Kept apart from [Refused] and [Failed] because the
|
||||
* remedy is different and specific — install a particular version — and a message that
|
||||
* says so is worth more than one that says "upload failed".
|
||||
*/
|
||||
data class Incompatible(val reason: String) : UploadOutcome
|
||||
data class Failed(val detail: String) : UploadOutcome
|
||||
data object NotConfigured : UploadOutcome
|
||||
}
|
||||
|
||||
private fun client() = ControlClient(
|
||||
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks the configured server without uploading anything: reachable, pinned, compatible, and
|
||||
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
|
||||
*/
|
||||
fun checkServer(): String {
|
||||
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
||||
return try {
|
||||
val profile = client().profile(settings.serverCredential)
|
||||
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||
when {
|
||||
compat.message != null -> head + "\n" + compat.message
|
||||
else -> {
|
||||
val uploads = profile.uploads.refusalReason()
|
||||
?: "uploads accepted (min anonymization: ${profile.uploads.minAnonymization})"
|
||||
head + "\nCompatible. " + uploads
|
||||
}
|
||||
}
|
||||
} catch (e: VersionRefused) {
|
||||
"This server will not serve this app: ${e.message}"
|
||||
} catch (t: Throwable) {
|
||||
"Could not reach the server: ${t.message ?: t.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one archived run to the configured server, redacting first.
|
||||
*
|
||||
@@ -81,8 +119,14 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
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 client = client()
|
||||
val profile = client.profile(settings.serverCredential)
|
||||
|
||||
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||
// policy it would never actually apply to us.
|
||||
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||
if (!compat.usable) return UploadOutcome.Incompatible(compat.message ?: "incompatible versions")
|
||||
|
||||
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
|
||||
|
||||
val level = PrivacyLevel.max(
|
||||
@@ -93,6 +137,8 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
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: VersionRefused) {
|
||||
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
|
||||
} catch (e: UploadRefused) {
|
||||
UploadOutcome.Refused(e.message ?: "refused by the server")
|
||||
} catch (t: Throwable) {
|
||||
|
||||
@@ -144,6 +144,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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.Incompatible -> "version mismatch: ${o.reason}"
|
||||
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
|
||||
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
|
||||
}
|
||||
@@ -195,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun archivedBytes(): Long = store.totalBytes()
|
||||
|
||||
/** Settings-screen action: report what the configured server is and whether we can use it. */
|
||||
fun checkServer() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(archiveStatus = "checking server …")
|
||||
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.checkServer() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-applies retention after the user changes the limits. */
|
||||
fun applyRetention() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -46,6 +46,8 @@ fun SettingsScreen(
|
||||
onApplyRetention: () -> Unit,
|
||||
onDeleteAll: () -> Unit,
|
||||
onPreviewUpload: () -> Unit,
|
||||
onCheckServer: () -> Unit,
|
||||
serverStatus: String?,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||
@@ -166,9 +168,23 @@ fun SettingsScreen(
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = onCheckServer) { Text("Check server") }
|
||||
Text(
|
||||
" " + if (settings.serverConfigured) "Configured."
|
||||
else "Uploads stay off until all three fields are set.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Version compatibility is checked here rather than discovered mid-run: a server
|
||||
// that will refuse this build should say so before a measurement is wasted.
|
||||
serverStatus?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(
|
||||
if (settings.serverConfigured) "Server configured."
|
||||
else "Uploads stay off until all three fields are set.",
|
||||
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
|
||||
"${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +
|
||||
"${app.echo_lot.protocol.Compat.serverRange}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user