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

@@ -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>