app: progress bar with ETA, cancel button, and cutout-safe layout
- Probe.estimatedMs (measured per probe; timeout-bound ones dominate) drives a determinate progress bar and "test N of M · ~Xs left", including the Shizuku battery in the total. - Cancel stops the run and shows the partial results as a normal document (findings + verdict over what was collected) but never uploads them. - safeDrawingPadding() on the root column: Android 15 is edge-to-edge by default and the title was colliding with the status-bar clock and the camera cutout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e34463c0a
commit
217818f7b3
@@ -495,3 +495,15 @@ The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 g
|
|||||||
raw material for the planned LLDP/mDNS cross-matching by MAC.
|
raw material for the planned LLDP/mDNS cross-matching by MAC.
|
||||||
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
|
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
|
||||||
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
|
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
|
||||||
|
|
||||||
|
### App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
|
||||||
|
- **Progress + ETA**: `Probe.estimatedMs` (per-probe, from measured on-device durations — the
|
||||||
|
timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s,
|
||||||
|
STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted
|
||||||
|
in the total so the bar covers the whole run.
|
||||||
|
- **Cancel**: stops an in-flight run and shows what was measured so far, assembled into a normal
|
||||||
|
document (findings + verdict over the partial set). Deliberately **does not upload** — a partial
|
||||||
|
run is for the person looking at the screen, not for the record.
|
||||||
|
- **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the
|
||||||
|
status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which
|
||||||
|
covers status bar, navigation bar and display cutout.
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
EcholotScreen(
|
EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
onRun = { vm.run() },
|
onRun = { vm.run() },
|
||||||
|
onCancel = vm::cancel,
|
||||||
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,9 +95,21 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (MeasurementDocument) -> Unit) {
|
private fun EcholotScreen(
|
||||||
|
state: UiState,
|
||||||
|
onRun: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onExport: (MeasurementDocument) -> Unit,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Android 15 draws edge-to-edge by default: without this the title runs under the
|
||||||
|
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
|
||||||
|
// AND the display cutout, so text never lands where it can't be read.
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -106,6 +119,9 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
|
|||||||
Button(onClick = onRun, enabled = !state.running) {
|
Button(onClick = onRun, enabled = !state.running) {
|
||||||
Text(if (state.running) "Running…" else "Run measurement")
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
}
|
}
|
||||||
|
if (state.running) {
|
||||||
|
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
||||||
|
}
|
||||||
state.document?.let { doc ->
|
state.document?.let { doc ->
|
||||||
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
|
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
|
||||||
}
|
}
|
||||||
@@ -116,9 +132,26 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
val frac = if (state.stepsTotal > 0)
|
||||||
Text(state.currentStep ?: "…", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { frac },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
if (state.stepsTotal > 0)
|
||||||
|
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||||
|
if (state.etaSeconds > 0) {
|
||||||
|
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ data class UiState(
|
|||||||
val document: MeasurementDocument? = null,
|
val document: MeasurementDocument? = null,
|
||||||
/** Result line of an automated upload (autorun mode); null when not attempted. */
|
/** Result line of an automated upload (autorun mode); null when not attempted. */
|
||||||
val uploadStatus: String? = null,
|
val uploadStatus: String? = null,
|
||||||
|
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
|
||||||
|
val stepsDone: Int = 0,
|
||||||
|
val stepsTotal: Int = 0,
|
||||||
|
val etaSeconds: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +50,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
var state by mutableStateOf(UiState())
|
var state by mutableStateOf(UiState())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var runJob: kotlinx.coroutines.Job? = null
|
||||||
|
// Results collected so far. A cancelled run must still be able to show what it measured.
|
||||||
|
private val collected = mutableListOf<Test>()
|
||||||
|
private var runIds: RunIds = RunIds()
|
||||||
|
private var runStartWall: String = ""
|
||||||
|
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
|
private var runShizukuOk = false
|
||||||
|
|
||||||
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
private class RunIds : ProbeIds {
|
private class RunIds : ProbeIds {
|
||||||
val originNanos = System.nanoTime()
|
val originNanos = System.nanoTime()
|
||||||
@@ -59,8 +71,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
*/
|
*/
|
||||||
fun run(upload: Boolean = false) {
|
fun run(upload: Boolean = false) {
|
||||||
if (state.running) return
|
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)
|
||||||
viewModelScope.launch {
|
runJob = viewModelScope.launch {
|
||||||
val doc = withContext(Dispatchers.IO) { measure() }
|
val doc = withContext(Dispatchers.IO) { measure() }
|
||||||
var status: String? = null
|
var status: String? = null
|
||||||
if (upload) {
|
if (upload) {
|
||||||
@@ -72,14 +85,28 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
|
||||||
|
* a partial run is for the person looking at the screen, not for the record.
|
||||||
|
*/
|
||||||
|
fun cancel() {
|
||||||
|
if (!state.running) return
|
||||||
|
runJob?.cancel()
|
||||||
|
val doc = buildDocument(collected.toList())
|
||||||
|
state = UiState(
|
||||||
|
running = false, currentStep = null, document = doc,
|
||||||
|
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun measure(): MeasurementDocument {
|
private suspend fun measure(): MeasurementDocument {
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
val ids = RunIds()
|
val ids = RunIds().also { runIds = it }
|
||||||
val startWall = Instant.now().toString()
|
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||||
|
|
||||||
step("reading networks")
|
step("reading networks")
|
||||||
val entries = NetworkInventory.snapshot(ctx)
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
val networks = entries.map { it.model }
|
val networks = entries.map { it.model }.also { runNetworks = it }
|
||||||
|
|
||||||
val probes: List<Probe> = listOf(
|
val probes: List<Probe> = listOf(
|
||||||
LinkSnapshotProbe(entries),
|
LinkSnapshotProbe(entries),
|
||||||
@@ -93,10 +120,18 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
|
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
|
||||||
|
val shizukuEstimateMs = 8_000L
|
||||||
|
val totalSteps = probes.size + 1
|
||||||
|
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
|
||||||
|
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
||||||
|
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
||||||
|
|
||||||
val tests = ArrayList<Test>()
|
val tests = ArrayList<Test>()
|
||||||
for (p in probes) {
|
for ((i, p) in probes.withIndex()) {
|
||||||
step(p.type)
|
step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
|
||||||
tests.add(
|
val result = (
|
||||||
try {
|
try {
|
||||||
p.run(ctx, ids)
|
p.run(ctx, ids)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -108,10 +143,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
tests.add(result); collected.add(result)
|
||||||
|
remainingMs -= p.estimatedMs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
|
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
|
||||||
step("shizuku.command_battery")
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
val shizukuTest = try {
|
val shizukuTest = try {
|
||||||
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -121,17 +158,20 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tests.add(shizukuTest)
|
tests.add(shizukuTest); collected.add(shizukuTest)
|
||||||
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
||||||
|
|
||||||
val findings = deriveFindings(tests, networks)
|
return buildDocument(tests)
|
||||||
val summary = Verdicts.derive(tests, findings)
|
}
|
||||||
|
|
||||||
|
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
||||||
|
private fun buildDocument(tests: List<Test>): MeasurementDocument {
|
||||||
|
val findings = deriveFindings(tests, runNetworks)
|
||||||
return MeasurementDocument(
|
return MeasurementDocument(
|
||||||
run = Run(
|
run = Run(
|
||||||
id = ids.uuid(), trigger = Trigger.MANUAL, startedAt = startWall,
|
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
||||||
endedAt = Instant.now().toString(),
|
endedAt = Instant.now().toString(),
|
||||||
clock = Clock(monoOriginWall = startWall),
|
clock = Clock(monoOriginWall = runStartWall),
|
||||||
app = AppInfo(
|
app = AppInfo(
|
||||||
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
|
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
|
||||||
),
|
),
|
||||||
@@ -139,12 +179,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
|
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
|
||||||
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
),
|
),
|
||||||
tiers = Tiers(app = true, shizuku = shizukuAvailable),
|
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||||
),
|
),
|
||||||
networks = networks,
|
networks = runNetworks,
|
||||||
tests = tests,
|
tests = tests,
|
||||||
findings = findings,
|
findings = findings,
|
||||||
summary = summary,
|
summary = Verdicts.derive(tests, findings),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +299,10 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun step(s: String) {
|
private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
|
||||||
state = state.copy(currentStep = s)
|
state = state.copy(
|
||||||
|
currentStep = s, stepsDone = done, stepsTotal = total,
|
||||||
|
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import java.net.URL
|
|||||||
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
override val type = TestType.NET_CAPTIVE_PORTAL
|
override val type = TestType.NET_CAPTIVE_PORTAL
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 9_000L // two HTTP probes per network, 4s timeouts each
|
||||||
|
|
||||||
private val httpsUrl = "https://www.google.com/generate_204"
|
private val httpsUrl = "https://www.google.com/generate_204"
|
||||||
private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204"
|
private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class DnsCanaryProbe(
|
|||||||
) : Probe {
|
) : Probe {
|
||||||
override val type = TestType.DNS_CANARY
|
override val type = TestType.DNS_CANARY
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 3_000L // five resolutions through the platform resolver
|
||||||
|
|
||||||
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
|
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
|
||||||
private val references = listOf(
|
private val references = listOf(
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class IcmpProbe(
|
|||||||
) : Probe {
|
) : Probe {
|
||||||
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
|
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
// v6 on a v4-only network waits out a 3s timeout per network; v4 answers in ms.
|
||||||
|
override val estimatedMs = if (v6) 7_000L else 800L
|
||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.addJsonObject
|
|||||||
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
override val type = TestType.LINK_SNAPSHOT
|
override val type = TestType.LINK_SNAPSHOT
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 200L // reads LinkProperties, no I/O
|
||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ interface Probe {
|
|||||||
val type: String
|
val type: String
|
||||||
val tier: Tier
|
val tier: Tier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough wall-clock estimate in ms, used only to drive the progress bar / ETA. Probes that
|
||||||
|
* wait on timeouts (ICMPv6 on a v4-only net, SSDP, STUN) dominate a run, so they override
|
||||||
|
* this with realistic values measured on device — a bad estimate misleads the user, it does
|
||||||
|
* not break the run.
|
||||||
|
*/
|
||||||
|
val estimatedMs: Long get() = 2_000
|
||||||
|
|
||||||
/** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so
|
/** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so
|
||||||
* results are attributable and use the two-clock rule. Must never throw. */
|
* results are attributable and use the two-clock rule. Must never throw. */
|
||||||
suspend fun run(ctx: Context, ids: ProbeIds): Test
|
suspend fun run(ctx: Context, ids: ProbeIds): Test
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import java.net.URL
|
|||||||
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
override val type = TestType.LINK_RA_SOURCE
|
override val type = TestType.LINK_RA_SOURCE
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 7_000L // SSDP M-SEARCH window + description fetches + rDNS
|
||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class StunProbe(
|
|||||||
) : Probe {
|
) : Probe {
|
||||||
override val type = TestType.NAT_STUN_5780
|
override val type = TestType.NAT_STUN_5780
|
||||||
override val tier = Tier.APP
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 6_000L // three binding requests; the change-request usually times out (3s)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAGIC_COOKIE = 0x2112A442.toInt()
|
const val MAGIC_COOKIE = 0x2112A442.toInt()
|
||||||
|
|||||||
Reference in New Issue
Block a user