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-video:1.5.2")
implementation("androidx.camera:camera-view: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("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,28 @@ import android.content.Context
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.os.Build 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 nsd = context.getSystemService(NsdManager::class.java)
private val serviceType = "_clawdis-bridge._tcp." private val serviceType = "_clawdis-bridge._tcp."
@@ -17,6 +33,9 @@ class BridgeDiscovery(context: Context) {
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList()) private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow() val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private var activeDomain: String = normalizeDomain(discoveryDomain.value)
private var unicastJob: Job? = null
private val discoveryListener = private val discoveryListener =
object : NsdManager.DiscoveryListener { object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {} override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
@@ -30,13 +49,40 @@ class BridgeDiscovery(context: Context) {
} }
override fun onServiceLost(serviceInfo: NsdServiceInfo) { override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo) val id = stableId(serviceInfo.serviceName, "local.")
byId.remove(id) byId.remove(id)
publish() publish()
} }
} }
init { 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 { try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) { } 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) { private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService( nsd.resolveService(
serviceInfo, serviceInfo,
@@ -56,7 +124,7 @@ class BridgeDiscovery(context: Context) {
if (port <= 0) return if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName 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) byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish() publish()
} }
@@ -68,8 +136,8 @@ class BridgeDiscovery(context: Context) {
_bridges.value = byId.values.sortedBy { it.name.lowercase() } _bridges.value = byId.values.sortedBy { it.name.lowercase() }
} }
private fun stableId(info: NsdServiceInfo): String { private fun stableId(serviceName: String, domain: String): String {
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}" return "${serviceType}|${domain}|${normalizeName(serviceName)}"
} }
private fun normalizeName(raw: String): String { private fun normalizeName(raw: String): String {
@@ -85,4 +153,86 @@ class BridgeDiscovery(context: Context) {
null 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 manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val discoveryDomain by viewModel.discoveryDomain.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.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") 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 { item {
OutlinedTextField( OutlinedTextField(
value = manualHost, 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 ## Prerequisites
- You can run the Gateway on the “master” machine. - 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). - You can run the CLI (`clawdis`) on the gateway machine (or via SSH).
## 1) Start the Gateway (with bridge enabled) ## 1) Start the Gateway (with bridge enabled)
@@ -29,6 +32,11 @@ pnpm clawdis gateway --port 18789 --verbose
Confirm in logs you see something like: Confirm in logs you see something like:
- `bridge listening on tcp://0.0.0.0:18790 (Iris)` - `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) ## 2) Verify discovery (optional)
From the gateway machine: From the gateway machine:
@@ -39,6 +47,16 @@ dns-sd -B _clawdis-bridge._tcp local.
More debugging notes: `docs/bonjour.md`. 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 ## 3) Connect from Android
In the Android app: In the Android app: