Android: add unicast discovery domain + app icon
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 148 KiB |
BIN
apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 267 KiB |
4
apps/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
</resources>
|
||||
|
||||
@@ -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 gateway’s 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 machine’s 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 won’t 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:
|
||||
|
||||