Android: add unicast discovery domain + app icon

This commit is contained in:
Peter Steinberger
2025-12-17 15:29:19 +01:00
parent 691bf85d7e
commit 036bdde764
22 changed files with 227 additions and 9 deletions

View File

@@ -74,6 +74,9 @@ dependencies {
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}

View File

@@ -16,6 +16,8 @@
<application
android:name=".NodeApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ClawdisNode">

View File

@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val discoveryDomain: StateFlow<String> = runtime.discoveryDomain
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError
@@ -56,6 +57,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setDiscoveryDomain(value: String) {
runtime.setDiscoveryDomain(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}

View File

@@ -91,7 +91,7 @@ class NodeForegroundService : Service() {
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)

View File

@@ -33,7 +33,7 @@ class NodeRuntime(context: Context) {
val camera = CameraCaptureManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val discovery = BridgeDiscovery(appContext)
private val discovery = BridgeDiscovery(appContext, scope = scope, discoveryDomain = prefs.discoveryDomain)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val _isConnected = MutableStateFlow(false)
@@ -82,6 +82,7 @@ class NodeRuntime(context: Context) {
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val discoveryDomain: StateFlow<String> = prefs.discoveryDomain
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
private var didAutoConnect = false
@@ -157,6 +158,10 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setDiscoveryDomain(value: String) {
prefs.setDiscoveryDomain(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()

View File

@@ -50,6 +50,9 @@ class SecurePrefs(context: Context) {
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
val manualPort: StateFlow<Int> = _manualPort
private val _discoveryDomain = MutableStateFlow(prefs.getString("bridge.discovery.domain", "")!!)
val discoveryDomain: StateFlow<String> = _discoveryDomain
private val _lastDiscoveredStableId =
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
@@ -90,6 +93,12 @@ class SecurePrefs(context: Context) {
_manualPort.value = value
}
fun setDiscoveryDomain(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.discovery.domain", trimmed).apply()
_discoveryDomain.value = trimmed
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)

View File

@@ -4,12 +4,28 @@ import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.xbill.DNS.Lookup
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
class BridgeDiscovery(context: Context) {
class BridgeDiscovery(
context: Context,
private val scope: CoroutineScope,
discoveryDomain: StateFlow<String>,
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val serviceType = "_clawdis-bridge._tcp."
@@ -17,6 +33,9 @@ class BridgeDiscovery(context: Context) {
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private var activeDomain: String = normalizeDomain(discoveryDomain.value)
private var unicastJob: Job? = null
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
@@ -30,13 +49,40 @@ class BridgeDiscovery(context: Context) {
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo)
val id = stableId(serviceInfo.serviceName, "local.")
byId.remove(id)
publish()
}
}
init {
scope.launch {
discoveryDomain.collect { raw ->
val normalized = normalizeDomain(raw)
if (normalized == activeDomain) return@collect
activeDomain = normalized
restartDiscovery()
}
}
restartDiscovery()
}
private fun restartDiscovery() {
byId.clear()
publish()
stopLocalDiscovery()
unicastJob?.cancel()
unicastJob = null
if (activeDomain == "local.") {
startLocalDiscovery()
} else {
startUnicastDiscovery(activeDomain)
}
}
private fun startLocalDiscovery() {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) {
@@ -44,6 +90,28 @@ class BridgeDiscovery(context: Context) {
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
while (true) {
try {
refreshUnicast(domain)
} catch (_: Throwable) {
// ignore (best-effort)
}
delay(5000)
}
}
}
private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService(
serviceInfo,
@@ -56,7 +124,7 @@ class BridgeDiscovery(context: Context) {
if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved)
val id = stableId(resolved.serviceName, "local.")
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
@@ -68,8 +136,8 @@ class BridgeDiscovery(context: Context) {
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
}
private fun stableId(info: NsdServiceInfo): String {
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
private fun stableId(serviceName: String, domain: String): String {
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
}
private fun normalizeName(raw: String): String {
@@ -85,4 +153,86 @@ class BridgeDiscovery(context: Context) {
null
}
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}"
val ptrRecords = lookup(ptrName, Type.PTR).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, BridgeEndpoint>()
for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString()
val srv = lookup(instanceFqdn, Type.SRV).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
val port = srv.port
if (port <= 0) continue
val targetName = stripTrailingDot(srv.target.toString())
val host =
try {
InetAddress.getAllByName(targetName)
.map { it.hostAddress }
.firstOrNull { !it.contains(":") } ?: InetAddress.getAllByName(targetName).firstOrNull()?.hostAddress
} catch (_: Throwable) {
null
} ?: continue
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord }
val instanceName = decodeInstanceName(instanceFqdn, domain)
val displayName = txtValue(txt, "displayName") ?: instanceName
val id = stableId(instanceName, domain)
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
}
byId.clear()
byId.putAll(next)
publish()
}
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
val suffix = "${serviceType}${domain}"
val withoutSuffix =
if (instanceFqdn.endsWith(suffix)) {
instanceFqdn.removeSuffix(suffix)
} else {
instanceFqdn.substringBefore(serviceType)
}
return normalizeName(stripTrailingDot(withoutSuffix))
}
private fun normalizeDomain(raw: String): String {
val trimmed = raw.trim().lowercase()
if (trimmed.isEmpty() || trimmed == "local" || trimmed == "local.") return "local."
return if (trimmed.endsWith(".")) trimmed else "$trimmed."
}
private fun stripTrailingDot(raw: String): String {
return raw.removeSuffix(".")
}
private fun lookup(name: String, type: Int): List<org.xbill.DNS.Record> {
return try {
val out = Lookup(name, type).run() ?: return emptyList()
out.toList()
} catch (_: Throwable) {
emptyList()
}
}
private fun txtValue(records: List<TXTRecord>, key: String): String? {
val prefix = "$key="
for (r in records) {
val strings =
try {
r.strings
} catch (_: Throwable) {
emptyList()
}
for (s in strings.filterNotNull()) {
val trimmed = s.trim()
if (trimmed.startsWith(prefix)) {
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
}
}
}
return null
}
}

View File

@@ -51,6 +51,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val discoveryDomain by viewModel.discoveryDomain.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -181,6 +182,15 @@ fun SettingsSheet(viewModel: MainViewModel) {
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
}
}
item {
OutlinedTextField(
value = discoveryDomain,
onValueChange = viewModel::setDiscoveryDomain,
label = { Text("Discovery Domain (leave empty for local.)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
OutlinedTextField(
value = manualHost,

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View File

@@ -0,0 +1,4 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
</resources>

View File

@@ -15,7 +15,10 @@ The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talk
## Prerequisites
- You can run the Gateway on the “master” machine.
- Android device/emulator is on the same LAN (mDNS must work) or you know the gateways LAN IP for manual connect.
- Android device/emulator can reach the gateway bridge:
- Same LAN with mDNS/NSD, **or**
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
- Manual bridge host/port (fallback)
- You can run the CLI (`clawdis`) on the gateway machine (or via SSH).
## 1) Start the Gateway (with bridge enabled)
@@ -29,6 +32,11 @@ pnpm clawdis gateway --port 18789 --verbose
Confirm in logs you see something like:
- `bridge listening on tcp://0.0.0.0:18790 (Iris)`
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machines Tailscale IP instead:
- Set `CLAWDIS_BRIDGE_HOST=<TAILNET_IPV4>` on the gateway host.
- Restart the Gateway / macOS menubar app.
## 2) Verify discovery (optional)
From the gateway machine:
@@ -39,6 +47,16 @@ dns-sd -B _clawdis-bridge._tcp local.
More debugging notes: `docs/bonjour.md`.
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
Android NSD/mDNS discovery wont cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records.
2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server.
3) In the Android app: Settings → Advanced → set **Discovery Domain** to `clawdis.internal.`
Details and example CoreDNS config: `docs/bonjour.md`.
## 3) Connect from Android
In the Android app: