Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a88a2d4b9 | ||
|
|
97515f7090 | ||
|
|
c414534e03 |
@@ -21,6 +21,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- So the relay survives a reboot of a device left running it (see RelayBootReceiver). -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
@@ -73,6 +75,19 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!--
|
||||
Exported because the system delivers these broadcasts; the receiver itself starts
|
||||
nothing unless the relay was already switched on in a debug build.
|
||||
-->
|
||||
<receiver
|
||||
android:name=".RelayBootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
@@ -102,12 +102,18 @@ class AdbRelay(
|
||||
// reachable one from a developer's subnet is the routable v4 address, and it is
|
||||
// also the only one worth relaying — a link-local address means nothing off-link.
|
||||
if (i.host !is Inet4Address) return
|
||||
// Only this device's own advertisement: on a shared network several phones may
|
||||
// Prefer this device's own advertisement: on a shared network several phones may
|
||||
// have wireless debugging on, and relaying a neighbour's port would send a
|
||||
// developer to the wrong device.
|
||||
if (host != localIp()) return
|
||||
// developer to the wrong device. But when this device cannot say what its own
|
||||
// address is, that is no reason to relay nothing — an unverified endpoint beats
|
||||
// silence, and it is labelled so it is never mistaken for a confirmed one.
|
||||
val mine = localIp()
|
||||
if (mine != null && host != mine) return
|
||||
lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
|
||||
onEvent("found adbd at $host:${i.port}")
|
||||
onEvent(
|
||||
if (mine == null) "found adbd at $host:${i.port} (own address unknown)"
|
||||
else "found adbd at $host:${i.port}"
|
||||
)
|
||||
}
|
||||
}
|
||||
runCatching { nsd?.resolveService(info, cb) }
|
||||
@@ -117,17 +123,21 @@ class AdbRelay(
|
||||
}
|
||||
}
|
||||
|
||||
/** This device's own IPv4 address on the wifi it is relaying from. */
|
||||
/**
|
||||
* This device's own IPv4 address on the network it is relaying from, or null if it cannot be
|
||||
* determined.
|
||||
*
|
||||
* Read from LinkProperties rather than `WifiManager.connectionInfo.ipAddress`, which is
|
||||
* deprecated and returns 0 to ordinary apps on current Android — a null that silently made the
|
||||
* ownership check reject every advertisement, so the relay found nothing and said nothing.
|
||||
*/
|
||||
private fun localIp(): String? = runCatching {
|
||||
val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null
|
||||
@Suppress("DEPRECATION")
|
||||
val ip = wifi.connectionInfo.ipAddress
|
||||
if (ip == 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
String.format(
|
||||
"%d.%d.%d.%d",
|
||||
ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
|
||||
)
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return null
|
||||
val lp = cm.getLinkProperties(cm.activeNetwork) ?: return null
|
||||
lp.linkAddresses.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -56,6 +56,11 @@ class AdbRelayService : Service() {
|
||||
r.start()
|
||||
|
||||
scope.launch {
|
||||
// Poll quickly until the first endpoint has actually been reported, then settle into
|
||||
// the heartbeat. Discovery takes a few seconds, so a loop that only ever waited the
|
||||
// heartbeat would see nothing on its first pass and then sit silent for two minutes —
|
||||
// exactly when someone has just switched the relay on and is watching for it to work.
|
||||
var reportedOnce = false
|
||||
while (true) {
|
||||
val ep = r.lastEndpoint
|
||||
if (ep != null) {
|
||||
@@ -66,13 +71,14 @@ class AdbRelayService : Service() {
|
||||
val result = post(settings, ep)
|
||||
status = if (result == null) {
|
||||
lastPosted = wire
|
||||
reportedOnce = true
|
||||
"reported $wire"
|
||||
} else {
|
||||
"found $wire, but reporting failed: $result"
|
||||
}
|
||||
notify(status)
|
||||
}
|
||||
delay(HEARTBEAT_MS)
|
||||
delay(if (reportedOnce) HEARTBEAT_MS else STARTUP_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +149,9 @@ class AdbRelayService : Service() {
|
||||
*/
|
||||
private const val HEARTBEAT_MS = 120_000L
|
||||
|
||||
/** Retry cadence before the first successful report; cheap, and only ever runs at start. */
|
||||
private const val STARTUP_POLL_MS = 5_000L
|
||||
|
||||
fun start(ctx: Context) {
|
||||
val i = Intent(ctx, AdbRelayService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
|
||||
|
||||
@@ -73,6 +73,9 @@ class MainActivity : ComponentActivity() {
|
||||
// there is no back stack to model beyond "return to the run screen".
|
||||
var screen by remember { mutableStateOf(Screen.RUN) }
|
||||
var preview by remember { mutableStateOf<String?>(null) }
|
||||
// Hoisted so the home-screen switch and the settings toggle cannot disagree
|
||||
// about whether the relay is on.
|
||||
var relayOn by remember { mutableStateOf(vm.settings.adbRelayEnabled) }
|
||||
// Automation entry point:
|
||||
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||
// starts a run immediately and uploads the report, so an unattended
|
||||
@@ -259,6 +262,8 @@ class MainActivity : ComponentActivity() {
|
||||
Screen.RUN -> EcholotScreen(
|
||||
state = vm.state,
|
||||
longMinutes = vm.settings.longRunMinutes,
|
||||
discoveryEnabled = { vm.settings.discoveryEnabled(it) },
|
||||
onDiscoveryChange = { id, on -> vm.settings.setDiscoveryEnabled(id, on) },
|
||||
onRun = { mode -> vm.run(mode) },
|
||||
onCancel = vm::cancel,
|
||||
onDeveloperOptions = {
|
||||
@@ -281,6 +286,19 @@ class MainActivity : ComponentActivity() {
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
relayOn = relayOn,
|
||||
// Dev builds only. The relay publishes where this device can be reached
|
||||
// over adb; that is scaffolding for driving a test device, and a release
|
||||
// build has no business offering it — it would be useless without
|
||||
// wireless debugging and a footgun for anyone who switched it on without
|
||||
// knowing what it announces.
|
||||
relayAvailable = BuildConfig.DEBUG && vm.settings.serverConfigured,
|
||||
onRelayToggle = { on ->
|
||||
relayOn = on
|
||||
vm.settings.adbRelayEnabled = on
|
||||
if (on) AdbRelayService.start(this@MainActivity)
|
||||
else AdbRelayService.stop(this@MainActivity)
|
||||
},
|
||||
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
|
||||
@@ -336,9 +354,19 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
// FlowRow: the discovery chips wrap rather than overflow on a narrow phone. Experimental only in
|
||||
// the sense that its API may gain parameters; the layout itself has been stable for releases.
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun EcholotScreen(
|
||||
state: UiState,
|
||||
longMinutes: Int,
|
||||
/** Which discovery listeners are selected, and how to change one. */
|
||||
discoveryEnabled: (String) -> Boolean,
|
||||
onDiscoveryChange: (String, Boolean) -> Unit,
|
||||
/** The relay is not a run mode, but it is switched on from here because that is where it is looked for. */
|
||||
relayOn: Boolean,
|
||||
relayAvailable: Boolean,
|
||||
onRelayToggle: (Boolean) -> Unit,
|
||||
onRun: (RunMode) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onShizukuAction: () -> Unit,
|
||||
@@ -431,6 +459,76 @@ private fun EcholotScreen(
|
||||
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Discovery listeners, chosen the same way as the mode and only where they mean anything.
|
||||
// They belong to the window: a passive listener in a quick run would mostly hear silence
|
||||
// and report an empty network as confidently as a quiet one.
|
||||
if (mode == RunMode.LONG) {
|
||||
var discovery by remember {
|
||||
mutableStateOf(DiscoveryIds.ALL.filter(discoveryEnabled).toSet())
|
||||
}
|
||||
Text(
|
||||
"Also listen for",
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (id in DiscoveryIds.ALL) {
|
||||
val on = id in discovery
|
||||
FilterChip(
|
||||
selected = on,
|
||||
onClick = {
|
||||
val next = !on
|
||||
onDiscoveryChange(id, next)
|
||||
discovery = if (next) discovery + id else discovery - id
|
||||
},
|
||||
enabled = !state.running,
|
||||
label = { Text(DiscoveryIds.label(id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
discovery.joinToString(" · ") { DiscoveryIds.blurb(it) }
|
||||
.ifBlank { "Nothing extra — just the measurements above." },
|
||||
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately NOT a third chip beside Quick and Long. Those choose how the next run
|
||||
// measures; this starts a service that keeps running afterwards and produces no document
|
||||
// at all, so putting it in the same row would promise that "Run measurement" starts it.
|
||||
// It lives here anyway because here is where it gets looked for.
|
||||
var relay by remember { mutableStateOf(relayOn) }
|
||||
if (relayAvailable || relay) Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Relay adb endpoint" + if (relay) " — on" else "",
|
||||
fontSize = 13.sp, fontWeight = FontWeight.Medium,
|
||||
color = LocalContentColor.current.copy(alpha = if (relayAvailable) 1f else 0.5f),
|
||||
)
|
||||
Text(
|
||||
if (!relayAvailable) {
|
||||
"Needs an enrolled server."
|
||||
} else if (relay) {
|
||||
"Reporting this device's wireless-debug host:port to the server."
|
||||
} else {
|
||||
"Not a measurement: lets a developer on another network find this " +
|
||||
"device when the port rotates."
|
||||
},
|
||||
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = relay,
|
||||
enabled = relayAvailable,
|
||||
onCheckedChange = { on -> relay = on; onRelayToggle(on) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = { onRun(mode) }, enabled = !state.running) {
|
||||
Text(if (state.running) "Running…" else "Run measurement")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.app
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Brings the relay back after a reboot or an app update, without anyone opening the app.
|
||||
*
|
||||
* The relay's whole purpose is to keep answering "where is this device" while the device sits on a
|
||||
* shelf unattended. Starting it only from [MainActivity] meant it silently did not come back from
|
||||
* either event — and a relay that has quietly stopped is worse than one that was never switched
|
||||
* on, because the endpoint it last published keeps looking authoritative while pointing at a port
|
||||
* nothing is listening on.
|
||||
*
|
||||
* `MY_PACKAGE_REPLACED` matters as much as boot here: installing a new build is the single most
|
||||
* common way this service dies during development, which is exactly when it is being relied on.
|
||||
*/
|
||||
class RelayBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
if (Settings(ctx).adbRelayEnabled) {
|
||||
runCatching { AdbRelayService.start(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,60 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
|
||||
* first does anything.
|
||||
*/
|
||||
/**
|
||||
* The discovery listeners the user selected for this run.
|
||||
*
|
||||
* Searches are paced across the window rather than fired at the start, so a device that was
|
||||
* asleep for the first minute is still asked.
|
||||
*/
|
||||
private fun discoveryCollectors(windowMs: Long): List<app.echo_lot.probe.Collector> {
|
||||
val out = ArrayList<app.echo_lot.probe.Collector>(4)
|
||||
if (settings.discoveryEnabled(DiscoveryIds.SSDP)) {
|
||||
out.add(
|
||||
app.echo_lot.probe.SsdpCollector(
|
||||
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
||||
)
|
||||
)
|
||||
}
|
||||
if (settings.discoveryEnabled(DiscoveryIds.WSD)) out.add(app.echo_lot.probe.WsdCollector())
|
||||
if (settings.discoveryEnabled(DiscoveryIds.LLMNR)) out.add(app.echo_lot.probe.LlmnrCollector())
|
||||
if (settings.discoveryEnabled(DiscoveryIds.NETBIOS)) out.add(app.echo_lot.probe.NetbiosCollector())
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A test recording that a listener was switched off, so its absence is never mistaken for its
|
||||
* silence.
|
||||
*
|
||||
* The same reasoning as `run.mode`: a document in which `local.ssdp_inventory` is simply
|
||||
* missing cannot tell a reader whether nothing announced itself or nobody was listening, and
|
||||
* those are opposite conclusions about a network. SKIPPED with a reason is how the canary and
|
||||
* STUN probes already say "not asked", so it is the shape a consumer already understands.
|
||||
*/
|
||||
private fun notSelected(type: String, ids: ProbeIds): Test {
|
||||
val at = ids.monoNs()
|
||||
return Test(
|
||||
id = ids.uuid(), type = type, tier = Tier.APP,
|
||||
startedMonoNs = at, endedMonoNs = at,
|
||||
status = TestStatus.SKIPPED,
|
||||
evidence = kotlinx.serialization.json.JsonObject(
|
||||
mapOf(
|
||||
"reason" to kotlinx.serialization.json.JsonPrimitive(
|
||||
"listener not selected for this run"
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Registry ids for the selectable listeners, so the skipped-test record uses the real type. */
|
||||
private fun discoveryType(id: String): String = when (id) {
|
||||
DiscoveryIds.SSDP -> TestType.LOCAL_SSDP_INVENTORY
|
||||
DiscoveryIds.WSD -> TestType.LOCAL_WSD_INVENTORY
|
||||
DiscoveryIds.LLMNR -> TestType.LOCAL_LLMNR_INVENTORY
|
||||
else -> TestType.LOCAL_NETBIOS_INVENTORY
|
||||
}
|
||||
|
||||
private suspend fun stopCollectors(): List<Test> {
|
||||
val running = activeCollectors
|
||||
activeCollectors = emptyList()
|
||||
@@ -507,13 +561,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
|
||||
// app tier cannot bind it — because a recorded reason beats an absent test, and
|
||||
// the decoder is ready for the Shizuku tier.
|
||||
app.echo_lot.probe.SsdpCollector(
|
||||
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
||||
),
|
||||
app.echo_lot.probe.LlmnrCollector(),
|
||||
app.echo_lot.probe.NetbiosCollector(),
|
||||
app.echo_lot.probe.WsdCollector(),
|
||||
)
|
||||
) + discoveryCollectors(windowMs)
|
||||
activeCollectors = collectors
|
||||
for (c in collectors) runCatching { c.start(ctx, ids) }
|
||||
// One ticker for the whole run: the battery does not report progress by the second, and
|
||||
|
||||
@@ -24,6 +24,7 @@ class Settings(context: Context) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
// ---- archive ---------------------------------------------------------------------
|
||||
|
||||
var archiveEnabled: Boolean
|
||||
@@ -80,6 +81,28 @@ class Settings(context: Context) {
|
||||
get() = prefs.getBoolean(ADB_RELAY, false)
|
||||
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
|
||||
|
||||
// ---- discovery listeners -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Which passive discovery listeners a long run starts.
|
||||
*
|
||||
* Selectable rather than fixed because they differ from the rest of the battery in kind: they
|
||||
* record what OTHER devices on the segment broadcast about themselves — hostnames, models,
|
||||
* printer names — and that is someone's choice to make per run, not a default to inherit.
|
||||
* They also cost nothing to leave off, since a listener that never starts cannot slow a run
|
||||
* down.
|
||||
*
|
||||
* NetBIOS is off by default alone among them: UDP 137 is privileged, so at app tier it can
|
||||
* only ever report `unsupported`, and shipping a listener that is guaranteed to fail as an
|
||||
* on-by-default option would train people to ignore the status column. It stays selectable —
|
||||
* the reason it reports is worth seeing once, and the tier that can bind it is coming.
|
||||
*/
|
||||
fun discoveryEnabled(id: String): Boolean =
|
||||
prefs.getBoolean("$DISCOVERY_PREFIX$id", id != DiscoveryIds.NETBIOS)
|
||||
|
||||
fun setDiscoveryEnabled(id: String, on: Boolean) =
|
||||
prefs.edit().putBoolean("$DISCOVERY_PREFIX$id", on).apply()
|
||||
|
||||
// ---- run-duration learning ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -276,6 +299,42 @@ class Settings(context: Context) {
|
||||
const val ACCOUNT_ID = "account_id"
|
||||
const val DURATION_PREFIX = "duration_ms."
|
||||
const val ADB_RELAY = "adb_relay_enabled"
|
||||
const val DISCOVERY_PREFIX = "discovery."
|
||||
const val LONG_RUN_MINUTES = "long_run_minutes"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids for the selectable discovery listeners.
|
||||
*
|
||||
* Top-level rather than nested in [Settings] because a class gets exactly one companion object and
|
||||
* that one is already spoken for by the preference keys, which stay private. Plain strings rather
|
||||
* than an enum: they key a stored preference, so a listener being added or retired must not need a
|
||||
* migration.
|
||||
*/
|
||||
object DiscoveryIds {
|
||||
const val SSDP = "ssdp"
|
||||
const val WSD = "wsd"
|
||||
const val LLMNR = "llmnr"
|
||||
const val NETBIOS = "netbios"
|
||||
|
||||
/** Display order: device inventory first, then the legacy name-resolution pair. */
|
||||
val ALL = listOf(SSDP, WSD, LLMNR, NETBIOS)
|
||||
|
||||
fun label(id: String): String = when (id) {
|
||||
SSDP -> "SSDP"
|
||||
WSD -> "WS-Discovery"
|
||||
LLMNR -> "LLMNR"
|
||||
NETBIOS -> "NetBIOS"
|
||||
else -> id
|
||||
}
|
||||
|
||||
/** Why someone would want this one, in the few words a chip's helper line allows. */
|
||||
fun blurb(id: String): String = when (id) {
|
||||
SSDP -> "UPnP devices announcing themselves"
|
||||
WSD -> "printers, scanners, cameras"
|
||||
LLMNR -> "Windows name lookups (and that it is enabled here)"
|
||||
NETBIOS -> "legacy Windows names — needs a privileged port, so app tier reports why not"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,10 +388,11 @@ fun SettingsScreen(
|
||||
|
||||
// ---- dev relay ------------------------------------------------------------------
|
||||
//
|
||||
// Last, and deliberately plain: this is scaffolding for driving a test device, not a
|
||||
// measurement. It publishes where this device can be reached over adb, which is why it
|
||||
// is off until someone decides otherwise.
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
// Debug builds only, and duplicated as a switch on the home screen because that is where
|
||||
// it is reached for. It publishes where this device can be reached over adb: scaffolding
|
||||
// for driving a test device, useless without wireless debugging, and not something a
|
||||
// release build should offer at all.
|
||||
if (BuildConfig.DEBUG) Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Developer relay", style = MaterialTheme.typography.titleMedium)
|
||||
Toggle(
|
||||
|
||||
Reference in New Issue
Block a user