Initial commit: capability prober + design docs
Monorepo root for Echolot. Contains the no-root capability prober (Kotlin/Compose, app.echo_lot.prober) and the four design docs that act as the contract for the production app and the Go server. LICENSE is deliberately absent — still undecided, see docs/build-status.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<!-- BLE (advertise capability probe). neverForLocation keeps us out of location gating. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- Shizuku -->
|
||||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="Echolot Prober"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.EcholotProber">
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Shizuku binder provider -->
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,11 @@
|
||||
// IUserService.aidl
|
||||
package app.echo_lot.prober.shizuku;
|
||||
|
||||
interface IUserService {
|
||||
// Runs when the service is unbound / destroyed.
|
||||
void destroy() = 16777114; // Shizuku reserves this transaction id for destroy
|
||||
|
||||
// Execute a shell command line as the Shizuku process user (shell uid 2000, or root),
|
||||
// returning combined stdout+stderr. Bounded output; the caller passes a timeout in ms.
|
||||
String exec(String command, int timeoutMs) = 1;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package app.echo_lot.prober
|
||||
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.echo_lot.prober.export.ReportWriter
|
||||
import app.echo_lot.prober.probe.ProbeRegistry
|
||||
import app.echo_lot.prober.probe.ProbeResult
|
||||
import app.echo_lot.prober.probe.Verdict
|
||||
import app.echo_lot.prober.ui.ProberScreen
|
||||
import app.echo_lot.prober.ui.UiState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var state by mutableStateOf(UiState())
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestRuntimePermissions()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
ProberScreen(
|
||||
state = state,
|
||||
onRun = ::runProbes,
|
||||
onShare = ::shareReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runProbes() {
|
||||
if (state.running) return
|
||||
state = state.copy(running = true, results = emptyList(), currentTitle = null)
|
||||
lifecycleScope.launch {
|
||||
val acc = mutableListOf<ProbeResult>()
|
||||
for (probe in ProbeRegistry.all) {
|
||||
state = state.copy(currentTitle = probe.title)
|
||||
val result = try {
|
||||
probe.run(this@MainActivity)
|
||||
} catch (t: Throwable) {
|
||||
ProbeResult.of(probe, Verdict.ERROR, "Uncaught: ${t.message ?: t.javaClass.simpleName}")
|
||||
}
|
||||
acc.add(result)
|
||||
state = state.copy(results = acc.toList())
|
||||
}
|
||||
state = state.copy(running = false, currentTitle = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareReport() {
|
||||
if (state.results.isEmpty()) return
|
||||
val intent = ReportWriter.share(this, state.results)
|
||||
startActivity(Intent.createChooser(intent, "Export Echolot prober report"))
|
||||
}
|
||||
|
||||
private fun requestRuntimePermissions() {
|
||||
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
perms += Manifest.permission.BLUETOOTH_ADVERTISE
|
||||
perms += Manifest.permission.BLUETOOTH_CONNECT
|
||||
}
|
||||
val missing = perms.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package app.echo_lot.prober.export
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import app.echo_lot.prober.probe.ProbeResult
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class Report(
|
||||
val schema: String = "echolot/prober-report",
|
||||
val schemaVersion: String = "0.1.0",
|
||||
val device: DeviceInfo,
|
||||
val results: List<ProbeResult>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceInfo(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidSdk: Int,
|
||||
val androidRelease: String,
|
||||
) {
|
||||
companion object {
|
||||
fun current() = DeviceInfo(
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
model = Build.MODEL,
|
||||
androidSdk = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ReportWriter {
|
||||
private val json = Json { prettyPrint = true; encodeDefaults = true }
|
||||
|
||||
fun toJson(results: List<ProbeResult>): String =
|
||||
json.encodeToString(Report(device = DeviceInfo.current(), results = results))
|
||||
|
||||
/** Writes the report to cache and returns a share Intent (SEND, application/json). */
|
||||
fun share(context: Context, results: List<ProbeResult>): Intent {
|
||||
val dir = File(context.cacheDir, "reports").apply { mkdirs() }
|
||||
val file = File(dir, "echolot-prober-${Build.MODEL.replace(' ', '_')}.json")
|
||||
file.writeText(toJson(results))
|
||||
val uri = FileProvider.getUriForFile(context, "${context.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,86 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Probes BLE peripheral advertising — the out-of-band control channel for peer mode. Not every
|
||||
* chipset supports LE advertising, so this is a real capability question. Requires runtime
|
||||
* BLUETOOTH_ADVERTISE (Android 12+); if not granted, we report INCONCLUSIVE.
|
||||
*/
|
||||
class BleAdvertiseProbe : Probe {
|
||||
override val id = "peer.ble_advertise"
|
||||
override val title = "BLE peripheral advertising (peer-mode control channel)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.Main) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
try {
|
||||
val hasFeature = context.packageManager
|
||||
.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
|
||||
ev["FEATURE_BLUETOOTH_LE"] = hasFeature.toString()
|
||||
|
||||
val bm = context.getSystemService(BluetoothManager::class.java)
|
||||
val adapter = bm?.adapter
|
||||
ev["adapter_enabled"] = (adapter?.isEnabled == true).toString()
|
||||
ev["multi_advertisement_supported"] =
|
||||
(adapter?.isMultipleAdvertisementSupported == true).toString()
|
||||
|
||||
val advertiser = adapter?.bluetoothLeAdvertiser
|
||||
if (advertiser == null) {
|
||||
return@withContext ProbeResult.of(this@BleAdvertiseProbe, Verdict.UNSUPPORTED,
|
||||
"No BLE advertiser (chipset or adapter off)", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setConnectable(true)
|
||||
.setTimeout(2000)
|
||||
.build()
|
||||
val data = AdvertiseData.Builder().setIncludeDeviceName(false).build()
|
||||
|
||||
val result = withTimeoutOrNull(4000) {
|
||||
suspendCancellableCoroutine<String> { cont ->
|
||||
val cb = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(s: AdvertiseSettings?) {
|
||||
if (cont.isActive) cont.resume("success")
|
||||
}
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
if (cont.isActive) cont.resume("failure:$errorCode")
|
||||
}
|
||||
}
|
||||
try {
|
||||
advertiser.startAdvertising(settings, data, cb)
|
||||
cont.invokeOnCancellation { runCatching { advertiser.stopAdvertising(cb) } }
|
||||
} catch (se: SecurityException) {
|
||||
if (cont.isActive) cont.resume("permission_denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
ev["startAdvertising"] = result ?: "timeout"
|
||||
val verdict = when (result) {
|
||||
"success" -> Verdict.SUPPORTED
|
||||
"permission_denied", null -> Verdict.INCONCLUSIVE
|
||||
else -> Verdict.UNSUPPORTED
|
||||
}
|
||||
ProbeResult.of(this@BleAdvertiseProbe, verdict,
|
||||
"Advertising start: ${ev["startAdvertising"]}", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@BleAdvertiseProbe, Verdict.ERROR, "BLE probe failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileDescriptor
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* Probes whether an errqueue-based traceroute is reachable from the Java/Kotlin Os surface.
|
||||
* The full technique needs recvmsg(MSG_ERRQUEUE) with cmsg parsing to pull the ICMP
|
||||
* time-exceeded source address. Os.recvmsg / StructMsghdr coverage varies by API level, so we
|
||||
* detect the call path via reflection and record the verdict. If unreachable here, the finding
|
||||
* is: "traceroute needs the native shim" (a few hundred lines of C over JNI) — which is exactly
|
||||
* what the production plan already earmarks.
|
||||
*/
|
||||
class ErrqueueProbe : Probe {
|
||||
override val id = "trace.errqueue_reachable"
|
||||
override val title = "Traceroute via IP_RECVERR + MSG_ERRQUEUE"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
var fd: FileDescriptor? = null
|
||||
try {
|
||||
fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP)
|
||||
val recverr = OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)
|
||||
ev["IP_RECVERR"] = recverr?.let { "reject: $it" } ?: "accepted"
|
||||
val ttl = OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_TTL, 1)
|
||||
ev["IP_TTL=1"] = ttl?.let { "reject: $it" } ?: "accepted"
|
||||
|
||||
// Fire one TTL=1 probe at a distant target so a first-hop router replies with
|
||||
// time-exceeded, populating the error queue.
|
||||
runCatching {
|
||||
Os.sendto(fd, ByteArray(32), 0, 32, 0, InetAddress.getByName("1.1.1.1"), 33434)
|
||||
}.onFailure { ev["sendto"] = it.message ?: "send failed" }
|
||||
|
||||
val hasMsghdr = classExists("android.system.StructMsghdr")
|
||||
val hasRecvmsg = Os::class.java.methods.any { it.name == "recvmsg" }
|
||||
ev["StructMsghdr"] = hasMsghdr.toString()
|
||||
ev["Os.recvmsg"] = hasRecvmsg.toString()
|
||||
|
||||
val sockoptsOk = recverr == null && ttl == null
|
||||
val verdict = when {
|
||||
sockoptsOk && hasRecvmsg && hasMsghdr -> Verdict.SUPPORTED
|
||||
sockoptsOk -> Verdict.PARTIAL // options work; error-queue read needs native shim
|
||||
else -> Verdict.UNSUPPORTED
|
||||
}
|
||||
val summary = when (verdict) {
|
||||
Verdict.SUPPORTED -> "Errqueue path fully reachable from Os API"
|
||||
Verdict.PARTIAL -> "Sockopts OK; MSG_ERRQUEUE read needs native shim (expected)"
|
||||
else -> "IP_RECVERR/IP_TTL not accepted"
|
||||
}
|
||||
ProbeResult.of(this@ErrqueueProbe, verdict, summary, ev, (System.nanoTime() - start) / 1_000_000)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@ErrqueueProbe, Verdict.ERROR, "errqueue probe failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
} finally {
|
||||
fd?.let { runCatching { Os.close(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun classExists(name: String) = runCatching { Class.forName(name) }.isSuccess
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.system.StructTimeval
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileDescriptor
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Probes the unprivileged ICMP echo path: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP).
|
||||
* Android opens ping_group_range to all UIDs, so this should work without root or CAP_NET_RAW.
|
||||
* If it does, the production app never needs to shell out to /system/bin/ping.
|
||||
*/
|
||||
class IcmpProbe(
|
||||
private val v6: Boolean = false,
|
||||
private val targetHost: String = if (v6) "2606:4700:4700::1111" else "1.1.1.1",
|
||||
) : Probe {
|
||||
override val id = if (v6) "icmp.ping6" else "icmp.ping4"
|
||||
override val title = if (v6) "ICMPv6 echo (unprivileged datagram socket)"
|
||||
else "ICMPv4 echo (unprivileged datagram socket)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
var fd: FileDescriptor? = null
|
||||
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)
|
||||
ev["socket"] = "opened family=$family proto=$proto"
|
||||
|
||||
Os.setsockoptTimeval(
|
||||
fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
|
||||
StructTimeval.fromMillis(3000),
|
||||
)
|
||||
|
||||
val addr = InetAddress.getByName(targetHost)
|
||||
ev["target"] = addr.hostAddress ?: targetHost
|
||||
|
||||
val ident = (Os.getpid() and 0xFFFF)
|
||||
val packet = buildEchoRequest(v6, ident.toShort(), seq = 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
|
||||
ev["bytes_received"] = received.toString()
|
||||
ev["rtt_ms"] = "%.1f".format(rttMs)
|
||||
|
||||
// ICMP datagram (ping) sockets deliver the ICMP message with NO IP header, so the
|
||||
// type byte is at offset 0 for both families. v4 echo reply = 0, v6 echo reply = 129.
|
||||
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
||||
ev["reply_type"] = replyType.toString()
|
||||
ev["reply_type_meaning"] = when (replyType) {
|
||||
0 -> "echo reply (v4)"; 129 -> "echo reply (v6)"; else -> "other/$replyType"
|
||||
}
|
||||
|
||||
ProbeResult.of(
|
||||
this@IcmpProbe, Verdict.SUPPORTED,
|
||||
"Echo reply from ${ev["target"]} in ${ev["rtt_ms"]} ms",
|
||||
ev, (System.nanoTime() - start) / 1_000_000,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
val verdict = if (isPermissionLike(e)) Verdict.UNSUPPORTED else Verdict.ERROR
|
||||
ProbeResult.of(
|
||||
this@IcmpProbe, verdict,
|
||||
"ICMP datagram socket failed: ${ev["error"]}",
|
||||
ev, (System.nanoTime() - start) / 1_000_000,
|
||||
)
|
||||
} finally {
|
||||
fd?.let { runCatching { Os.close(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPermissionLike(e: Throwable): Boolean {
|
||||
val m = (e.message ?: "").uppercase()
|
||||
return "EACCES" in m || "EPERM" in m || "EAFNOSUPPORT" in m || "EPROTONOSUPPORT" in m
|
||||
}
|
||||
|
||||
/** Minimal ICMP echo request; kernel fills checksum for ICMPv6, we compute it for ICMPv4. */
|
||||
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
|
||||
val type = if (v6) 128 else 8 // echo request
|
||||
val payload = "echolot-prober".toByteArray()
|
||||
val pkt = ByteBuffer.allocate(8 + payload.size)
|
||||
pkt.put(type.toByte()) // type
|
||||
pkt.put(0) // code
|
||||
pkt.putShort(0) // checksum placeholder
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Reads what the platform will hand us for free about every active Network: addresses, routes,
|
||||
* DNS, MTU, NAT64 prefix, private-DNS state. This is the app-tier baseline the whole IPv4/IPv6
|
||||
* config analysis is built on. Also surfaces DhcpInfo indirectly (via route/DNS/gateway).
|
||||
*/
|
||||
class LinkPropertiesProbe : Probe {
|
||||
override val id = "link.snapshot"
|
||||
override val title = "LinkProperties snapshot (all active networks)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
try {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java)
|
||||
val nets = cm.allNetworks
|
||||
ev["network_count"] = nets.size.toString()
|
||||
var idx = 0
|
||||
for (n in nets) {
|
||||
val caps: NetworkCapabilities? = cm.getNetworkCapabilities(n)
|
||||
val lp: LinkProperties? = cm.getLinkProperties(n)
|
||||
val transport = when {
|
||||
caps == null -> "?"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
|
||||
else -> "other"
|
||||
}
|
||||
val p = "net$idx.$transport"
|
||||
if (lp != null) {
|
||||
ev["$p.iface"] = lp.interfaceName ?: "?"
|
||||
ev["$p.mtu"] = lp.mtu.toString()
|
||||
ev["$p.addrs"] = lp.linkAddresses.joinToString(", ") { it.toString() }
|
||||
ev["$p.dns"] = lp.dnsServers.joinToString(", ") { it.hostAddress ?: "?" }
|
||||
ev["$p.routes"] = lp.routes.joinToString(" | ") { it.toString() }
|
||||
ev["$p.domains"] = lp.domains ?: ""
|
||||
runCatching { ev["$p.nat64"] = lp.nat64Prefix?.toString() ?: "none" }
|
||||
runCatching { ev["$p.private_dns"] = lp.privateDnsServerName ?: "off/opportunistic" }
|
||||
}
|
||||
idx++
|
||||
}
|
||||
val verdict = if (nets.isNotEmpty()) Verdict.SUPPORTED else Verdict.INCONCLUSIVE
|
||||
ProbeResult.of(this@LinkPropertiesProbe, verdict,
|
||||
"${nets.size} active network(s) read", ev, (System.nanoTime() - start) / 1_000_000)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@LinkPropertiesProbe, Verdict.ERROR, "read failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Probes the headline no-root feature: holding Wi-Fi, cellular, and USB ethernet up at once via
|
||||
* requestNetwork, then binding a socket per network. This is what lets the app run the same
|
||||
* battery over every path simultaneously and diff them. We request each transport and report
|
||||
* which ones produced a bindable Network within a timeout.
|
||||
*/
|
||||
class MultiNetworkProbe : Probe {
|
||||
override val id = "multinetwork.request_and_bind"
|
||||
override val title = "Concurrent per-network binding (Wi-Fi / cellular / ethernet)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
private val transports = listOf(
|
||||
"wifi" to NetworkCapabilities.TRANSPORT_WIFI,
|
||||
"cellular" to NetworkCapabilities.TRANSPORT_CELLULAR,
|
||||
"ethernet" to NetworkCapabilities.TRANSPORT_ETHERNET,
|
||||
)
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java)
|
||||
var bound = 0
|
||||
try {
|
||||
for ((name, transport) in transports) {
|
||||
val net: Network? = withTimeoutOrNull(4000) { requestNetwork(cm, transport) }
|
||||
if (net == null) {
|
||||
ev[name] = "no network within 4s"
|
||||
continue
|
||||
}
|
||||
val bindOk = runCatching {
|
||||
val s = java.net.Socket()
|
||||
net.bindSocket(s)
|
||||
s.close()
|
||||
true
|
||||
}.getOrElse { false }
|
||||
val caps = cm.getNetworkCapabilities(net)
|
||||
val down = caps?.linkDownstreamBandwidthKbps ?: -1
|
||||
ev[name] = "network acquired; bindSocket=${if (bindOk) "ok" else "FAILED"}; downKbps=$down"
|
||||
if (bindOk) bound++
|
||||
}
|
||||
val verdict = when {
|
||||
bound >= 2 -> Verdict.SUPPORTED
|
||||
bound == 1 -> Verdict.PARTIAL
|
||||
else -> Verdict.INCONCLUSIVE
|
||||
}
|
||||
ProbeResult.of(this@MultiNetworkProbe, verdict,
|
||||
"$bound transport(s) acquired and bound (only currently-present links can bind)",
|
||||
ev, (System.nanoTime() - start) / 1_000_000)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@MultiNetworkProbe, Verdict.ERROR, "multi-network probe failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestNetwork(cm: ConnectivityManager, transport: Int): Network =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val req = NetworkRequest.Builder()
|
||||
.addTransportType(transport)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (cont.isActive) cont.resume(network)
|
||||
}
|
||||
}
|
||||
cm.requestNetwork(req, cb)
|
||||
cont.invokeOnCancellation { runCatching { cm.unregisterNetworkCallback(cb) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Probes multicast reception (MulticastLock + NSD/mDNS discovery). Confirms the app can do the
|
||||
* mDNS/SSDP/LLMNR service inventory that doubles as the VLAN-leakage detector. Uses NsdManager
|
||||
* as the least-privileged path; a raw 224.0.0.251:5353 listener is the fuller implementation.
|
||||
*/
|
||||
class MulticastProbe : Probe {
|
||||
override val id = "local.mdns_discover"
|
||||
override val title = "Multicast reception (MulticastLock + mDNS/NSD)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
val wifi = context.getSystemService(WifiManager::class.java)
|
||||
val lock = wifi?.createMulticastLock("echolot-prober")?.apply {
|
||||
setReferenceCounted(false)
|
||||
runCatching { acquire() }
|
||||
}
|
||||
ev["multicast_lock"] = if (lock?.isHeld == true) "acquired" else "not held"
|
||||
|
||||
val nsd = context.getSystemService(NsdManager::class.java)
|
||||
val found = AtomicInteger(0)
|
||||
val started = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val serviceType = "_services._dns-sd._udp." // meta-query: enumerates service types
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(t: String?, code: Int) {}
|
||||
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
|
||||
override fun onDiscoveryStarted(t: String?) { started.set(true) }
|
||||
override fun onDiscoveryStopped(t: String?) {}
|
||||
override fun onServiceFound(s: NsdServiceInfo?) { found.incrementAndGet() }
|
||||
override fun onServiceLost(s: NsdServiceInfo?) {}
|
||||
}
|
||||
try {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
delay(4000)
|
||||
runCatching { nsd.stopServiceDiscovery(listener) }
|
||||
ev["discovery_started"] = started.get().toString()
|
||||
ev["services_found"] = found.get().toString()
|
||||
val verdict = when {
|
||||
started.get() -> Verdict.SUPPORTED
|
||||
else -> Verdict.INCONCLUSIVE
|
||||
}
|
||||
ProbeResult.of(this@MulticastProbe, verdict,
|
||||
"mDNS discovery ${if (started.get()) "ran" else "did not start"}; ${found.get()} service type(s) seen",
|
||||
ev, (System.nanoTime() - start) / 1_000_000)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(this@MulticastProbe, Verdict.ERROR, "mDNS probe failed", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
} finally {
|
||||
runCatching { lock?.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.system.Os
|
||||
import java.io.FileDescriptor
|
||||
|
||||
/**
|
||||
* Linux socket-option ABI numbers that android.system.OsConstants does NOT reliably expose.
|
||||
* These are stable across Android's supported ABIs at the IP/IPv6 protocol levels, which is
|
||||
* exactly why a prober can hardcode them: if setsockoptInt with one of these succeeds, the
|
||||
* kernel accepted the option; if it throws ErrnoException, it did not. Either outcome is data.
|
||||
*/
|
||||
object OsAbi {
|
||||
// IP level
|
||||
const val IP_TTL = 2
|
||||
const val IP_MTU_DISCOVER = 10
|
||||
const val IP_MTU = 14
|
||||
const val IP_RECVERR = 11
|
||||
const val IP_PMTUDISC_DO = 2 // set DF, honor PMTU
|
||||
const val IP_PMTUDISC_PROBE = 3 // set DF, ignore PMTU (for probing)
|
||||
|
||||
// IPv6 level
|
||||
const val IPV6_MTU_DISCOVER = 23
|
||||
const val IPV6_MTU = 24
|
||||
const val IPV6_RECVERR = 25
|
||||
const val IPV6_UNICAST_HOPS = 16
|
||||
const val IPV6_PMTUDISC_PROBE = 3
|
||||
|
||||
/** Try setsockoptInt; return null on success, or the errno name on failure. */
|
||||
fun trySetIntOpt(fd: FileDescriptor, level: Int, opt: Int, value: Int): String? =
|
||||
try {
|
||||
Os.setsockoptInt(fd, level, opt, value)
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* getsockoptInt is not part of the stable public Os surface on every API level, so we reach
|
||||
* it via reflection and let the prober report whether the call path even exists.
|
||||
*/
|
||||
fun tryGetIntOpt(fd: FileDescriptor, level: Int, opt: Int): Result<Int> = runCatching {
|
||||
val m = Os::class.java.getMethod(
|
||||
"getsockoptInt",
|
||||
FileDescriptor::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
m.invoke(null, fd, level, opt) as Int
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A capability probe. Each probe attempts one borderline no-root operation from the Echolot
|
||||
* feasibility matrix and reports whether it worked on THIS device / Android build, plus the
|
||||
* raw evidence needed to understand why.
|
||||
*
|
||||
* The prober's whole point is to turn the spec's "should work / needs verification" notes into
|
||||
* observed facts on real hardware before the production app hardens around them.
|
||||
*/
|
||||
interface Probe {
|
||||
/** Stable id, dotted, mirrors the measurement-schema test type where one exists. */
|
||||
val id: String
|
||||
|
||||
/** One-line human description shown in the UI. */
|
||||
val title: String
|
||||
|
||||
/** Which trust tier this probe exercises. */
|
||||
val tier: Tier
|
||||
|
||||
suspend fun run(context: Context): ProbeResult
|
||||
}
|
||||
|
||||
enum class Tier { APP, SHIZUKU }
|
||||
|
||||
enum class Verdict {
|
||||
/** Capability confirmed working. */
|
||||
SUPPORTED,
|
||||
/** Capability partially works (e.g. sockopt accepted but result read needs native). */
|
||||
PARTIAL,
|
||||
/** Capability not available on this device/build. */
|
||||
UNSUPPORTED,
|
||||
/** Could not determine (missing permission, no matching network, timeout). */
|
||||
INCONCLUSIVE,
|
||||
/** Probe threw unexpectedly. */
|
||||
ERROR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProbeResult(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val tier: String,
|
||||
val verdict: String,
|
||||
/** Short conclusion, e.g. "ICMP datagram socket usable; RTT 14 ms to 1.1.1.1". */
|
||||
val summary: String,
|
||||
/** Raw key/value evidence (sockopt return codes, addresses, dump excerpts, timings). */
|
||||
val evidence: Map<String, String> = emptyMap(),
|
||||
val durationMs: Long = 0,
|
||||
) {
|
||||
companion object {
|
||||
fun of(
|
||||
probe: Probe,
|
||||
verdict: Verdict,
|
||||
summary: String,
|
||||
evidence: Map<String, String> = emptyMap(),
|
||||
durationMs: Long = 0,
|
||||
) = ProbeResult(
|
||||
id = probe.id,
|
||||
title = probe.title,
|
||||
tier = probe.tier.name,
|
||||
verdict = verdict.name,
|
||||
summary = summary,
|
||||
evidence = evidence,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
/** All probes the app runs, in display order. */
|
||||
object ProbeRegistry {
|
||||
val all: List<Probe> = listOf(
|
||||
LinkPropertiesProbe(),
|
||||
IcmpProbe(v6 = false),
|
||||
IcmpProbe(v6 = true),
|
||||
SockOptProbe(),
|
||||
ErrqueueProbe(),
|
||||
MultiNetworkProbe(),
|
||||
MulticastProbe(),
|
||||
BleAdvertiseProbe(),
|
||||
ShizukuProbe(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.prober.shizuku.ShizukuRunner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Probes the Shizuku tier end to end: is the binder alive, is permission granted, does a bound
|
||||
* UserService run as shell/root, and does the shell-privilege command battery actually return
|
||||
* the data the production app wants (neighbor table, RA-derived routes with lifetimes, live
|
||||
* netlink monitor, IpClient DHCP logs). Every command's output is captured as evidence so we can
|
||||
* see the real, per-device dump format the parsers must handle.
|
||||
*/
|
||||
class ShizukuProbe : Probe {
|
||||
override val id = "shizuku.command_battery"
|
||||
override val title = "Shizuku shell tier (ip neigh / route / dumpsys network_stack)"
|
||||
override val tier = Tier.SHIZUKU
|
||||
|
||||
// Kept short so the whole battery finishes quickly; ip monitor is time-bounded with timeout.
|
||||
private val battery = listOf(
|
||||
"id" to "id",
|
||||
"ip_neigh" to "ip neigh show",
|
||||
"ip6_route" to "ip -6 route show table all",
|
||||
"ip_addr" to "ip addr show",
|
||||
"ip_monitor" to "timeout 2 ip monitor all || true",
|
||||
"dhcp_log" to "dumpsys network_stack 2>/dev/null | grep -iA2 -m 20 -e dhcp -e 'IpClient' || true",
|
||||
"wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true",
|
||||
)
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
val runner = ShizukuRunner(context)
|
||||
val st = runner.status()
|
||||
ev["binder_alive"] = st.binderAlive.toString()
|
||||
ev["version"] = st.version.toString()
|
||||
ev["runs_as"] = st.uidName
|
||||
ev["permission"] = st.permissionGranted.toString()
|
||||
|
||||
if (!st.binderAlive) {
|
||||
return@withContext ProbeResult.of(this@ShizukuProbe, Verdict.INCONCLUSIVE,
|
||||
"Shizuku not running (start Shizuku via wireless ADB and retry)", ev,
|
||||
(System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
if (!st.permissionGranted) {
|
||||
val granted = runner.requestPermission()
|
||||
ev["permission_after_request"] = granted.toString()
|
||||
if (!granted) {
|
||||
return@withContext ProbeResult.of(this@ShizukuProbe, Verdict.INCONCLUSIVE,
|
||||
"Shizuku permission not granted", ev, (System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
var ok = 0
|
||||
for ((key, cmd) in battery) {
|
||||
val out = runner.exec(cmd, timeoutMs = 6000)
|
||||
// Truncate each excerpt so evidence stays readable; full capture is future work.
|
||||
ev[key] = out.trim().take(1200)
|
||||
if (!out.startsWith("SHIZUKU_") && !out.startsWith("EXEC_") && out.isNotBlank()) ok++
|
||||
}
|
||||
val verdict = if (ok >= 4) Verdict.SUPPORTED else if (ok >= 1) Verdict.PARTIAL else Verdict.UNSUPPORTED
|
||||
ProbeResult.of(this@ShizukuProbe, verdict,
|
||||
"Shizuku runs as ${st.uidName}; $ok/${battery.size} commands returned data",
|
||||
ev, (System.nanoTime() - start) / 1_000_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package app.echo_lot.prober.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileDescriptor
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
/**
|
||||
* Probes the setsockopt families the production probe engine depends on: TTL control
|
||||
* (traceroute), IP_RECVERR (errqueue-based traceroute + PMTUD), and DF / IP_MTU_DISCOVER
|
||||
* (MTU probing). We only need to learn which options the kernel accepts here; actually
|
||||
* reading the error queue is the native shim's job and is probed separately.
|
||||
*/
|
||||
class SockOptProbe : Probe {
|
||||
override val id = "sockopt.matrix"
|
||||
override val title = "Socket options: TTL, RECVERR, MTU_DISCOVER (DF)"
|
||||
override val tier = Tier.APP
|
||||
|
||||
override suspend fun run(context: Context): ProbeResult = withContext(Dispatchers.IO) {
|
||||
val start = System.nanoTime()
|
||||
val ev = LinkedHashMap<String, String>()
|
||||
var fd: FileDescriptor? = null
|
||||
try {
|
||||
fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_UDP)
|
||||
|
||||
ev["IP_TTL"] = OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_TTL, 5)
|
||||
?.let { "reject: $it" } ?: "accepted (ttl=5)"
|
||||
|
||||
ev["IP_TOS/DSCP"] = OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsConstants.IP_TOS, 0xB8)
|
||||
?.let { "reject: $it" } ?: "accepted (EF/46)"
|
||||
|
||||
ev["IP_RECVERR"] = OsAbi.trySetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_RECVERR, 1)
|
||||
?.let { "reject: $it" } ?: "accepted"
|
||||
|
||||
ev["IP_MTU_DISCOVER=PROBE"] = OsAbi.trySetIntOpt(
|
||||
fd, OsConstants.IPPROTO_IP, OsAbi.IP_MTU_DISCOVER, OsAbi.IP_PMTUDISC_PROBE,
|
||||
)?.let { "reject: $it" } ?: "accepted (DF set)"
|
||||
|
||||
// After connecting, IP_MTU should report the path MTU guess.
|
||||
runCatching {
|
||||
Os.connect(fd, InetAddress.getByName("1.1.1.1"), 33434)
|
||||
}
|
||||
val mtu = OsAbi.tryGetIntOpt(fd, OsConstants.IPPROTO_IP, OsAbi.IP_MTU)
|
||||
ev["IP_MTU(read)"] = mtu.fold(
|
||||
onSuccess = { "$it (getsockoptInt reachable)" },
|
||||
onFailure = { "unreadable: ${it.message ?: it.javaClass.simpleName}" },
|
||||
)
|
||||
|
||||
val accepted = ev.values.count { it.startsWith("accepted") }
|
||||
val verdict = when {
|
||||
accepted >= 4 -> Verdict.SUPPORTED
|
||||
accepted >= 2 -> Verdict.PARTIAL
|
||||
else -> Verdict.UNSUPPORTED
|
||||
}
|
||||
ProbeResult.of(
|
||||
this@SockOptProbe, verdict,
|
||||
"$accepted/4 core sockopts accepted; IP_MTU read=${mtu.isSuccess}",
|
||||
ev, (System.nanoTime() - start) / 1_000_000,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
ev["error"] = e.message ?: e.javaClass.simpleName
|
||||
ProbeResult.of(
|
||||
this@SockOptProbe, Verdict.ERROR,
|
||||
"sockopt probe failed: ${ev["error"]}", ev,
|
||||
(System.nanoTime() - start) / 1_000_000,
|
||||
)
|
||||
} finally {
|
||||
fd?.let { runCatching { Os.close(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app.echo_lot.prober.shizuku
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rikka.shizuku.Shizuku
|
||||
|
||||
/**
|
||||
* Thin wrapper over the Shizuku binder + a bound UserService. Reports availability, permission,
|
||||
* and can run shell commands as the shell/root user.
|
||||
*/
|
||||
class ShizukuRunner(private val context: Context) {
|
||||
|
||||
data class Status(
|
||||
val binderAlive: Boolean,
|
||||
val version: Int,
|
||||
val uidName: String,
|
||||
val permissionGranted: Boolean,
|
||||
)
|
||||
|
||||
fun status(): Status {
|
||||
val alive = runCatching { Shizuku.pingBinder() }.getOrDefault(false)
|
||||
val version = if (alive) runCatching { Shizuku.getVersion() }.getOrDefault(-1) else -1
|
||||
val uid = if (alive) runCatching { Shizuku.getUid() }.getOrDefault(-1) else -1
|
||||
val uidName = when (uid) {
|
||||
0 -> "root(0)"
|
||||
2000 -> "shell(2000)"
|
||||
-1 -> "unknown"
|
||||
else -> "uid=$uid"
|
||||
}
|
||||
val granted = alive && runCatching {
|
||||
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
}.getOrDefault(false)
|
||||
return Status(alive, version, uidName, granted)
|
||||
}
|
||||
|
||||
suspend fun requestPermission(): Boolean {
|
||||
val s = status()
|
||||
if (!s.binderAlive) return false
|
||||
if (s.permissionGranted) return true
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
val code = 0xE1
|
||||
val listener = object : Shizuku.OnRequestPermissionResultListener {
|
||||
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
|
||||
if (requestCode == code) {
|
||||
Shizuku.removeRequestPermissionResultListener(this)
|
||||
deferred.complete(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
}
|
||||
}
|
||||
Shizuku.addRequestPermissionResultListener(listener)
|
||||
runCatching { Shizuku.requestPermission(code) }
|
||||
return withTimeoutOrNull(30000) { deferred.await() } ?: false
|
||||
}
|
||||
|
||||
private val serviceArgs = Shizuku.UserServiceArgs(
|
||||
ComponentName(context.packageName, UserService::class.java.name),
|
||||
)
|
||||
.daemon(false)
|
||||
.processNameSuffix("prober")
|
||||
.version(1)
|
||||
|
||||
/** Bind the UserService, run one command, and return combined output (or an error string). */
|
||||
suspend fun exec(command: String, timeoutMs: Int = 8000): String {
|
||||
val bound = CompletableDeferred<IUserService?>()
|
||||
val conn = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
bound.complete(
|
||||
if (binder != null && binder.pingBinder()) IUserService.Stub.asInterface(binder) else null,
|
||||
)
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {}
|
||||
}
|
||||
return try {
|
||||
Shizuku.bindUserService(serviceArgs, conn)
|
||||
val svc = withTimeoutOrNull(10000) { bound.await() }
|
||||
?: return "SHIZUKU_BIND_TIMEOUT"
|
||||
withTimeoutOrNull(timeoutMs.toLong() + 4000) { svc.exec(command, timeoutMs) }
|
||||
?: "EXEC_TIMEOUT"
|
||||
} catch (e: Throwable) {
|
||||
"SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}"
|
||||
} finally {
|
||||
runCatching { Shizuku.unbindUserService(serviceArgs, conn, true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package app.echo_lot.prober.shizuku
|
||||
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Runs inside the Shizuku-spawned process (uid = shell, 2000, or root if Shizuku was started via
|
||||
* root). Because this process has the shell SELinux domain, commands here can read `ip neigh`,
|
||||
* `ip monitor`, `dumpsys network_stack`, etc. — things the app UID cannot.
|
||||
*
|
||||
* This is the canonical Shizuku UserService pattern: a plain class with a matching constructor,
|
||||
* implementing the AIDL Stub.
|
||||
*/
|
||||
class UserService : IUserService.Stub() {
|
||||
|
||||
// Required no-arg constructor for Shizuku UserService.
|
||||
constructor()
|
||||
|
||||
override fun destroy() {
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
override fun exec(command: String, timeoutMs: Int): String {
|
||||
return try {
|
||||
val proc = ProcessBuilder("sh", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = StringBuilder()
|
||||
val reader = proc.inputStream.bufferedReader()
|
||||
val deadline = System.currentTimeMillis() + timeoutMs.coerceIn(500, 30000)
|
||||
val readerThread = Thread {
|
||||
runCatching {
|
||||
reader.forEachLine { line ->
|
||||
if (output.length < 64 * 1024) output.append(line).append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
readerThread.start()
|
||||
while (readerThread.isAlive && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(20)
|
||||
}
|
||||
if (proc.isAlive) proc.destroy()
|
||||
"uid=" + android.os.Process.myUid() + "\n" + output.toString()
|
||||
} catch (e: Throwable) {
|
||||
"EXEC_ERROR: ${e.message ?: e.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package app.echo_lot.prober.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
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 app.echo_lot.prober.probe.ProbeResult
|
||||
|
||||
data class UiState(
|
||||
val running: Boolean = false,
|
||||
val currentTitle: String? = null,
|
||||
val results: List<ProbeResult> = emptyList(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ProberScreen(
|
||||
state: UiState,
|
||||
onRun: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Echolot Capability Prober", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Runs the borderline no-root probes from the feasibility matrix on THIS device and reports what actually works.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onRun, enabled = !state.running) {
|
||||
Text(if (state.running) "Running…" else "Run all probes")
|
||||
}
|
||||
OutlinedButton(onClick = onShare, enabled = state.results.isNotEmpty() && !state.running) {
|
||||
Text("Export JSON")
|
||||
}
|
||||
}
|
||||
if (state.running) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.padding(2.dp))
|
||||
Text(state.currentTitle ?: "Starting…", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.results) { r -> ResultCard(r) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultCard(r: ProbeResult) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = verdictColor(r.verdict).copy(alpha = 0.10f)),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(r.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
r.verdict,
|
||||
color = verdictColor(r.verdict),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Text("${r.id} · ${r.tier} · ${r.durationMs} ms", fontSize = 11.sp, color = Color.Gray)
|
||||
Text(r.summary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp))
|
||||
if (r.evidence.isNotEmpty()) {
|
||||
Column(Modifier.padding(top = 6.dp)) {
|
||||
r.evidence.forEach { (k, v) ->
|
||||
Text(
|
||||
"$k = $v",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = Color.DarkGray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verdictColor(v: String): Color = when (v) {
|
||||
"SUPPORTED" -> Color(0xFF2E7D32)
|
||||
"PARTIAL" -> Color(0xFFF9A825)
|
||||
"UNSUPPORTED" -> Color(0xFFC62828)
|
||||
"INCONCLUSIVE" -> Color(0xFF1565C0)
|
||||
else -> Color(0xFF6A1B9A)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Echolot Prober</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.EcholotProber" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="reports" path="reports/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user