Android: add unicast discovery domain + app icon
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
## 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 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).
|
- 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 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)
|
## 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 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
|
## 3) Connect from Android
|
||||||
|
|
||||||
In the Android app:
|
In the Android app:
|
||||||
|
|||||||