diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index d723a388e..361e9c244 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -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") } diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 7703e3a69..530e7f689 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,8 @@ diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index 527aa470a..92a360c94 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualEnabled: StateFlow = runtime.manualEnabled val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort + val discoveryDomain: StateFlow = runtime.discoveryDomain val chatMessages: StateFlow> = runtime.chatMessages val chatError: StateFlow = 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) { runtime.setWakeWords(words) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt index b68f004cb..660e20e75 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt @@ -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) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index ccaf791d3..0c2867463 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -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> = discovery.bridges private val _isConnected = MutableStateFlow(false) @@ -82,6 +82,7 @@ class NodeRuntime(context: Context) { val manualEnabled: StateFlow = prefs.manualEnabled val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort + val discoveryDomain: StateFlow = prefs.discoveryDomain val lastDiscoveredStableId: StateFlow = 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) { prefs.setWakeWords(words) scheduleWakeWordsSyncIfNeeded() diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt index b6dd875e8..d41195535 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt @@ -50,6 +50,9 @@ class SecurePrefs(context: Context) { private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790)) val manualPort: StateFlow = _manualPort + private val _discoveryDomain = MutableStateFlow(prefs.getString("bridge.discovery.domain", "")!!) + val discoveryDomain: StateFlow = _discoveryDomain + private val _lastDiscoveredStableId = MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!) val lastDiscoveredStableId: StateFlow = _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) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt index 3d4307885..ffcaa1759 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt @@ -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, +) { 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>(emptyList()) val bridges: StateFlow> = _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() + 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 { + return try { + val out = Lookup(name, type).run() ?: return emptyList() + out.toList() + } catch (_: Throwable) { + emptyList() + } + } + + private fun txtValue(records: List, 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 + } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index 6bfa0b428..e11d0ef59 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -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, diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..a65c12916 --- /dev/null +++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..a65c12916 --- /dev/null +++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..57f688136 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..2b3c8accb Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c1ede159a Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..66546872d Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ad9666528 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..e3c0dd80e Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..483ef3131 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b30b54290 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..e476abb38 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..69245c02e Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..6e79939c6 --- /dev/null +++ b/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + #0A0A0A + + diff --git a/docs/android/connect.md b/docs/android/connect.md index 6f3cc9ad1..c7456b8b2 100644 --- a/docs/android/connect.md +++ b/docs/android/connect.md @@ -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=` 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: