app: installable APK — core-probe (device-tier) + Compose UI
First assembling build of the production app. Android toolchain mirrors the prober (AGP 9 built-in Kotlin; applying kotlin.android too double-registers the kotlin extension — the one gotcha). core-probe (Android lib): Probe→core-measurement Test abstraction; NetworkInventory (LinkProperties→networks[]), LinkSnapshotProbe, per-network IcmpProbe (ported from the prober's validated logic). app (Compose): RunViewModel orchestrates probes into a MeasurementDocument with a §7.3 summary + first-pass findings; UI renders traffic lights, networks, tests, findings; JSON export. Rotation-safe (ViewModel). App-tier only; server-facing (core-engine) + Shizuku are additive follow-ups. Debug APK 9.5 MB, assembles clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49c6197aff
commit
bc220f950e
@@ -328,3 +328,18 @@ Two more pure-Kotlin/JVM modules, both verifiable without a device:
|
|||||||
So the whole server-facing stack — protocol client → engine → schema document → verdict — is now
|
So the whole server-facing stack — protocol client → engine → schema document → verdict — is now
|
||||||
proven against the live server, no device needed. Next modules (core-probe device-tier,
|
proven against the live server, no device needed. Next modules (core-probe device-tier,
|
||||||
core-shizuku dual-path, Compose app) are Android + need on-device verification.
|
core-shizuku dual-path, Compose app) are Android + need on-device verification.
|
||||||
|
|
||||||
|
## App: installable APK — core-probe + Compose UI (2026-07-31)
|
||||||
|
The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP
|
||||||
|
9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the `kotlin`
|
||||||
|
extension; that was the one build gotcha). Modules added:
|
||||||
|
- **core-probe** (Android lib): Probe→core-measurement Test abstraction; NetworkInventory
|
||||||
|
(LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe
|
||||||
|
(per-network icmp.ping4/6, ported from the prober's validated per-network logic).
|
||||||
|
- **app** (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a
|
||||||
|
§7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights,
|
||||||
|
networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation
|
||||||
|
(ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive
|
||||||
|
follow-ups (app degrades gracefully without them, like the prober).
|
||||||
|
Debug APK: 9.5 MB, `echolot-app/app/build/outputs/apk/debug/app-debug.apk`. Not yet run on device
|
||||||
|
(needs the user's phone). core-shizuku (dual-path executor) deferred as additive.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin — no kotlin.android here (see core-probe note).
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.app"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.echo_lot.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release { isMinifyEnabled = false }
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(project(":core-protocol"))
|
||||||
|
implementation(project(":core-engine"))
|
||||||
|
implementation(project(":core-probe"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="Echolot"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Echolot">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
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.text.font.FontFamily
|
||||||
|
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.viewmodel.compose.viewModel
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
requestRuntimePermissions()
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
val vm: RunViewModel = viewModel()
|
||||||
|
EcholotScreen(
|
||||||
|
state = vm.state,
|
||||||
|
onRun = vm::run,
|
||||||
|
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestRuntimePermissions() {
|
||||||
|
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
val missing = perms.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||||
|
Verdict.GREEN -> Color(0xFF2E7D32)
|
||||||
|
Verdict.YELLOW -> Color(0xFFF9A825)
|
||||||
|
Verdict.RED -> Color(0xFFC62828)
|
||||||
|
Verdict.INCONCLUSIVE -> Color(0xFF616161)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColor(s: TestStatus): Color = when (s) {
|
||||||
|
TestStatus.OK -> Color(0xFF66BB6A)
|
||||||
|
TestStatus.PARTIAL -> Color(0xFFFFB300)
|
||||||
|
TestStatus.FAILED -> Color(0xFFEF5350)
|
||||||
|
TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (MeasurementDocument) -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(16.dp).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(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = onRun, enabled = !state.running) {
|
||||||
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
|
}
|
||||||
|
state.document?.let { doc ->
|
||||||
|
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.running) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
Text(state.currentStep ?: "…", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.document?.let { doc -> Results(doc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Results(doc: MeasurementDocument) {
|
||||||
|
val summary = doc.summary
|
||||||
|
if (summary != null) {
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowCategories(summary.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle("Networks (${doc.networks.size})")
|
||||||
|
for (n in doc.networks) {
|
||||||
|
Text("• ${n.transport.name.lowercase()} ${n.iface ?: ""} — " +
|
||||||
|
n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" },
|
||||||
|
fontSize = 13.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle("Tests (${doc.tests.size})")
|
||||||
|
for (t in doc.tests) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Dot(statusColor(t.status))
|
||||||
|
Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||||
|
Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000
|
||||||
|
Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.findings.isNotEmpty()) {
|
||||||
|
SectionTitle("Findings (${doc.findings.size})")
|
||||||
|
for (f in doc.findings) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(f.title, fontWeight = FontWeight.Medium)
|
||||||
|
Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(f.description, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for ((name, cat) in categories) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Dot(verdictColor(cat.verdict))
|
||||||
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
|
Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "",
|
||||||
|
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dot(color: Color) {
|
||||||
|
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 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.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */
|
||||||
|
object Report {
|
||||||
|
private val json = Json { prettyPrint = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
fun toJson(doc: MeasurementDocument): String =
|
||||||
|
json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
|
||||||
|
fun share(ctx: Context, doc: MeasurementDocument): Intent {
|
||||||
|
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
|
||||||
|
val file = File(dir, "echolot-run-${doc.run.id}.json")
|
||||||
|
file.writeText(toJson(doc))
|
||||||
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||||
|
return Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/json"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.probe.IcmpProbe
|
||||||
|
import app.echo_lot.probe.LinkSnapshotProbe
|
||||||
|
import app.echo_lot.probe.NetworkInventory
|
||||||
|
import app.echo_lot.probe.Probe
|
||||||
|
import app.echo_lot.probe.ProbeIds
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val running: Boolean = false,
|
||||||
|
val currentStep: String? = null,
|
||||||
|
val document: MeasurementDocument? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;
|
||||||
|
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
|
||||||
|
* survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a
|
||||||
|
* follow-up once enrollment UI lands.
|
||||||
|
*/
|
||||||
|
class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
var state by mutableStateOf(UiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
|
private class RunIds : ProbeIds {
|
||||||
|
val originNanos = System.nanoTime()
|
||||||
|
override fun uuid(): String = UUID.randomUUID().toString()
|
||||||
|
override fun monoNs(): Long = System.nanoTime() - originNanos
|
||||||
|
}
|
||||||
|
|
||||||
|
fun run() {
|
||||||
|
if (state.running) return
|
||||||
|
state = state.copy(running = true, currentStep = "starting", document = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val doc = withContext(Dispatchers.IO) { measure() }
|
||||||
|
state = UiState(running = false, currentStep = null, document = doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measure(): MeasurementDocument {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val ids = RunIds()
|
||||||
|
val startWall = Instant.now().toString()
|
||||||
|
|
||||||
|
step("reading networks")
|
||||||
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
|
val networks = entries.map { it.model }
|
||||||
|
|
||||||
|
val probes: List<Probe> = listOf(
|
||||||
|
LinkSnapshotProbe(entries),
|
||||||
|
IcmpProbe(entries, v6 = false),
|
||||||
|
IcmpProbe(entries, v6 = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tests = ArrayList<Test>()
|
||||||
|
for (p in probes) {
|
||||||
|
step(p.type)
|
||||||
|
tests.add(
|
||||||
|
try {
|
||||||
|
p.run(ctx, ids)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Test(
|
||||||
|
id = ids.uuid(), type = p.type, tier = p.tier,
|
||||||
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED,
|
||||||
|
error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val findings = deriveFindings(tests)
|
||||||
|
val summary = Verdicts.derive(tests, findings)
|
||||||
|
|
||||||
|
return MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = ids.uuid(), trigger = Trigger.MANUAL, startedAt = startWall,
|
||||||
|
endedAt = Instant.now().toString(),
|
||||||
|
clock = Clock(monoOriginWall = startWall),
|
||||||
|
app = AppInfo(
|
||||||
|
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
|
||||||
|
),
|
||||||
|
device = DeviceInfo(
|
||||||
|
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
|
||||||
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
|
),
|
||||||
|
tiers = Tiers(app = true),
|
||||||
|
),
|
||||||
|
networks = networks,
|
||||||
|
tests = tests,
|
||||||
|
findings = findings,
|
||||||
|
summary = summary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal first-pass findings from device-tier evidence; the full findings registry grows
|
||||||
|
* with the test suite. */
|
||||||
|
private fun deriveFindings(tests: List<Test>): List<Finding> {
|
||||||
|
val out = ArrayList<Finding>()
|
||||||
|
val ids = RunIds()
|
||||||
|
for (t in tests) {
|
||||||
|
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.no_icmp_path", category = Category.IPV6,
|
||||||
|
severity = Severity.LOW, confidence = Confidence.MEDIUM,
|
||||||
|
title = "No IPv6 ICMP path on any active network",
|
||||||
|
description = "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun step(s: String) {
|
||||||
|
state = state.copy(currentStep = s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Echolot" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="reports" path="reports/" />
|
||||||
|
</paths>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 has built-in Kotlin — do NOT also apply kotlin.android (double-registers
|
||||||
|
// the `kotlin` extension). Only the serialization compiler plugin is added.
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-tier probes (Android platform APIs), emitting core-measurement Test
|
||||||
|
// objects. Ported/adapted from the validated echolot-prober. minSdk 26 to
|
||||||
|
// match the prober and the feasibility findings.
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.probe"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Network
|
||||||
|
import android.system.Os
|
||||||
|
import android.system.OsConstants
|
||||||
|
import android.system.StructTimeval
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
|
||||||
|
* (Network.bindSocket). Ported from the prober, which validated on real hardware that Android's
|
||||||
|
* open ping_group_range makes this work with no root — and that per-network binding turns a
|
||||||
|
* default-network v6 EAGAIN into topology evidence rather than a false failure.
|
||||||
|
*/
|
||||||
|
class IcmpProbe(
|
||||||
|
private val entries: List<NetworkInventory.Entry>,
|
||||||
|
private val v6: Boolean,
|
||||||
|
private val target: String = if (v6) "2606:4700:4700::1111" else "1.1.1.1",
|
||||||
|
) : Probe {
|
||||||
|
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val perNetwork = LinkedHashMap<String, String>()
|
||||||
|
var anyOk = false
|
||||||
|
val rtts = ArrayList<Double>()
|
||||||
|
|
||||||
|
// Default network first, then each active network explicitly.
|
||||||
|
attempt(null).let { (ok, detail, rtt) ->
|
||||||
|
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
|
}
|
||||||
|
for (e in entries) {
|
||||||
|
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||||
|
val (ok, detail, rtt) = attempt(e.handle)
|
||||||
|
perNetwork[label] = detail
|
||||||
|
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("target", target)
|
||||||
|
for ((k, v) in perNetwork) put(k, v)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("networks_ok", rtts.size)
|
||||||
|
rtts.minOrNull()?.let { put("rtt_ms_min", round1(it)) }
|
||||||
|
if (rtts.isNotEmpty()) put("rtt_ms_avg", round1(rtts.average()))
|
||||||
|
rtts.maxOrNull()?.let { put("rtt_ms_max", round1(it)) }
|
||||||
|
}
|
||||||
|
val status = if (anyOk) TestStatus.OK else TestStatus.FAILED
|
||||||
|
b.build(status, evidence = evidence, metrics = metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Attempt(val ok: Boolean, val detail: String, val rttMs: Double?)
|
||||||
|
|
||||||
|
private fun attempt(network: Network?): Attempt {
|
||||||
|
var fd: FileDescriptor? = null
|
||||||
|
return try {
|
||||||
|
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
||||||
|
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
||||||
|
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
|
||||||
|
network?.bindSocket(fd)
|
||||||
|
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
|
||||||
|
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
|
||||||
|
|
||||||
|
val ident = (Os.getpid() and 0xFFFF)
|
||||||
|
val packet = buildEchoRequest(v6, ident.toShort(), 1)
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
|
||||||
|
val buf = ByteBuffer.allocate(1500)
|
||||||
|
val received = Os.recvfrom(fd, buf, 0, null)
|
||||||
|
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||||
|
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
||||||
|
val ok = replyType == (if (v6) 129 else 0)
|
||||||
|
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}", null)
|
||||||
|
} finally {
|
||||||
|
fd?.let { runCatching { Os.close(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
|
||||||
|
val type = if (v6) 128 else 8
|
||||||
|
val payload = "echolot".toByteArray()
|
||||||
|
val pkt = ByteBuffer.allocate(8 + payload.size)
|
||||||
|
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
|
||||||
|
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
|
||||||
|
val bytes = pkt.array()
|
||||||
|
if (!v6) {
|
||||||
|
val cs = checksum(bytes)
|
||||||
|
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checksum(b: ByteArray): Short {
|
||||||
|
var sum = 0; var i = 0
|
||||||
|
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
|
||||||
|
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
|
||||||
|
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
|
||||||
|
return sum.inv().toShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonArray
|
||||||
|
import kotlinx.serialization.json.addJsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* link.snapshot: records every active network's LinkProperties as evidence. The full network
|
||||||
|
* models also feed the document's `networks[]` (see [NetworkInventory]); this test captures the
|
||||||
|
* count and a compact per-network summary so the snapshot is attributable in `tests[]`.
|
||||||
|
*/
|
||||||
|
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
|
override val type = TestType.LINK_SNAPSHOT
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("network_count", entries.size)
|
||||||
|
putJsonArray("networks") {
|
||||||
|
for (e in entries) addJsonObject {
|
||||||
|
put("id", e.model.id)
|
||||||
|
put("transport", e.model.transport.name.lowercase())
|
||||||
|
put("interface", e.model.iface ?: "")
|
||||||
|
put("mtu", e.model.link.mtu ?: 0)
|
||||||
|
put("addresses", e.model.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" })
|
||||||
|
put("dns", (e.model.link.dns?.servers ?: emptyList()).joinToString(", "))
|
||||||
|
put("nat64", e.model.link.dns?.nat64Prefix ?: "none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
|
||||||
|
return b.build(status, evidence = evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import app.echo_lot.measurement.Address
|
||||||
|
import app.echo_lot.measurement.DnsConfig
|
||||||
|
import app.echo_lot.measurement.Link
|
||||||
|
import app.echo_lot.measurement.Route
|
||||||
|
import app.echo_lot.measurement.Transport
|
||||||
|
import app.echo_lot.measurement.Network as MNetwork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the app-tier snapshot of every active Android Network into measurement `networks[]`
|
||||||
|
* (measurement-schema.md §4). App tier fills what LinkProperties exposes; route proto and address
|
||||||
|
* lifetimes are Shizuku-tier and left absent (absence = "not observed"). Ported from the prober's
|
||||||
|
* LinkPropertiesProbe.
|
||||||
|
*/
|
||||||
|
object NetworkInventory {
|
||||||
|
|
||||||
|
/** One measurement Network per active Android Network, plus the Android Network handle so
|
||||||
|
* server/ICMP probes can bind to it. */
|
||||||
|
data class Entry(val model: MNetwork, val handle: android.net.Network)
|
||||||
|
|
||||||
|
fun snapshot(ctx: Context): List<Entry> {
|
||||||
|
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val out = ArrayList<Entry>()
|
||||||
|
var idx = 0
|
||||||
|
for (net in cm.allNetworks) {
|
||||||
|
val caps = cm.getNetworkCapabilities(net) ?: continue
|
||||||
|
val lp = cm.getLinkProperties(net) ?: continue
|
||||||
|
out.add(Entry(model = toModel("net-${idx}", caps, lp), handle = net))
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
|
||||||
|
val transport = when {
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Transport.CELLULAR
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Transport.ETHERNET
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> Transport.VPN
|
||||||
|
else -> Transport.OTHER
|
||||||
|
}
|
||||||
|
val addresses = lp.linkAddresses.map {
|
||||||
|
Address(
|
||||||
|
addr = it.address.hostAddress ?: it.address.toString(),
|
||||||
|
prefixLen = it.prefixLength,
|
||||||
|
scope = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val routes = lp.routes.map {
|
||||||
|
Route(
|
||||||
|
dst = it.destination.toString(),
|
||||||
|
gateway = it.gateway?.hostAddress,
|
||||||
|
iface = it.`interface`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val nat64 = runCatching { lp.nat64Prefix?.toString() }.getOrNull()
|
||||||
|
val dns = DnsConfig(
|
||||||
|
servers = lp.dnsServers.mapNotNull { it.hostAddress },
|
||||||
|
privateDnsMode = if (lp.isPrivateDnsActive) "strict/opportunistic" else "off",
|
||||||
|
privateDnsHostname = lp.privateDnsServerName,
|
||||||
|
searchDomains = lp.domains?.split(",")?.map { it.trim() } ?: emptyList(),
|
||||||
|
nat64Prefix = nat64,
|
||||||
|
)
|
||||||
|
return MNetwork(
|
||||||
|
id = id, transport = transport, iface = lp.interfaceName,
|
||||||
|
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package probe holds the device-tier probes (app + shizuku tiers). Each probe runs a platform
|
||||||
|
// measurement and returns a core-measurement [Test] — raw evidence + recomputable metrics — never
|
||||||
|
// throwing to the caller. Ported from the validated echolot-prober, now emitting the production
|
||||||
|
// schema instead of the prober's ad-hoc format.
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** A single device-tier measurement. [type] is a TestType registry id. */
|
||||||
|
interface Probe {
|
||||||
|
val type: String
|
||||||
|
val tier: Tier
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
suspend fun run(ctx: Context, ids: ProbeIds): Test
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injected UUID/clock source (measurement-schema.md: UUIDv7 ids, *_mono_ns math clock). */
|
||||||
|
interface ProbeIds {
|
||||||
|
fun uuid(): String
|
||||||
|
/** Monotonic nanoseconds relative to the run's mono origin. */
|
||||||
|
fun monoNs(): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a [Test] envelope, capturing start/end from the shared clock. */
|
||||||
|
class TestBuilder(
|
||||||
|
private val type: String,
|
||||||
|
private val tier: Tier,
|
||||||
|
private val ids: ProbeIds,
|
||||||
|
private val networkRef: String? = null,
|
||||||
|
private val sessionRef: String? = null,
|
||||||
|
) {
|
||||||
|
private val id = ids.uuid()
|
||||||
|
private val startedMonoNs = ids.monoNs()
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
status: TestStatus,
|
||||||
|
evidence: JsonObject? = null,
|
||||||
|
metrics: JsonObject? = null,
|
||||||
|
error: TestError? = null,
|
||||||
|
): Test = Test(
|
||||||
|
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
|
||||||
|
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
|
||||||
|
status = status, error = error, evidence = evidence, metrics = metrics,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -1,10 +1,34 @@
|
|||||||
[versions]
|
[versions]
|
||||||
|
agp = "9.2.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
activityCompose = "1.9.3"
|
||||||
|
composeBom = "2024.10.01"
|
||||||
|
shizuku = "13.1.5"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
|
||||||
|
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ rootProject.name = "echolot-app"
|
|||||||
include(":core-protocol")
|
include(":core-protocol")
|
||||||
include(":core-measurement")
|
include(":core-measurement")
|
||||||
include(":core-engine")
|
include(":core-engine")
|
||||||
|
include(":core-probe")
|
||||||
|
include(":app")
|
||||||
|
|||||||
Reference in New Issue
Block a user