diff --git a/app/src/main/java/com/dccbigfred/android/discovery/DiscoveredServer.kt b/app/src/main/java/com/dccbigfred/android/discovery/DiscoveredServer.kt index a919e32..14db198 100644 --- a/app/src/main/java/com/dccbigfred/android/discovery/DiscoveredServer.kt +++ b/app/src/main/java/com/dccbigfred/android/discovery/DiscoveredServer.kt @@ -10,4 +10,7 @@ data class DiscoveredServer( val baseUrl: String, val label: String, val source: DiscoverySource, + val host: String, + val port: Int, + val ipv4: String? = null, ) diff --git a/app/src/main/java/com/dccbigfred/android/discovery/DiscoveryPolicy.kt b/app/src/main/java/com/dccbigfred/android/discovery/DiscoveryPolicy.kt new file mode 100644 index 0000000..26ded50 --- /dev/null +++ b/app/src/main/java/com/dccbigfred/android/discovery/DiscoveryPolicy.kt @@ -0,0 +1,84 @@ +package com.dccbigfred.android.discovery + +/** + * Pure helpers for the loco-server picker: keep `bigfred-component=main`, + * drop wizard/OS-UI/Grafana, and collapse hostname + IP duplicates. + */ +object DiscoveryPolicy { + const val TXT_COMPONENT = "bigfred-component" + const val COMPONENT_MAIN = "main" + const val DEFAULT_PORT = 8080 + + private val ipv4Literal = Regex("""^(\d{1,3}\.){3}\d{1,3}$""") + private val nonMainNames = setOf( + "bigfred-wizard", + "bigfred-os-ui", + "grafana", + ) + + /** + * Whether this DNS-SD row is the loco-server the picker should offer. + * + * When TXT `bigfred-component` is present, only `main` is accepted. + * On older hubs without the TXT, require port 8080 and skip known + * non-main instance names. + */ + fun isMainServer(component: String?, serviceName: String, port: Int): Boolean { + val role = component?.trim()?.lowercase().orEmpty() + if (role.isNotEmpty()) { + return role == COMPONENT_MAIN + } + val name = serviceName.trim().lowercase() + if (name in nonMainNames) return false + return port == DEFAULT_PORT + } + + fun isIpLiteral(host: String): Boolean { + val h = host.trim().removePrefix("[").removeSuffix("]") + if (ipv4Literal.matches(h)) return true + if (':' !in h) return false + return h.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' || it == '.' } + } + + /** + * Insert [incoming], collapsing rows that share IPv4+port (or the same + * base URL). Hostname wins over an IP literal for the same endpoint. + */ + fun upsert( + current: List, + incoming: DiscoveredServer, + ): List { + val duplicate = current.filter { sameEndpoint(it, incoming) } + if (duplicate.isEmpty()) { + return sort(current + incoming) + } + val kept = prefer(duplicate + incoming) + val without = current.filterNot { sameEndpoint(it, incoming) } + return sort(without + kept) + } + + internal fun sameEndpoint(a: DiscoveredServer, b: DiscoveredServer): Boolean { + if (a.baseUrl == b.baseUrl) return true + if (a.port != b.port) return false + val aIp = a.ipv4 + val bIp = b.ipv4 + if (!aIp.isNullOrBlank() && aIp == bIp) return true + return a.host.equals(b.host, ignoreCase = true) + } + + internal fun prefer(candidates: List): DiscoveredServer { + return candidates.minWith( + compareBy { if (isIpLiteral(it.host)) 1 else 0 } + .thenBy { it.source.ordinal } + .thenBy { it.label }, + ) + } + + private fun sort(rows: List): List { + return rows.sortedWith( + compareBy { it.source.ordinal } + .thenBy { if (isIpLiteral(it.host)) 1 else 0 } + .thenBy { it.label }, + ) + } +} diff --git a/app/src/main/java/com/dccbigfred/android/discovery/ServerDiscovery.kt b/app/src/main/java/com/dccbigfred/android/discovery/ServerDiscovery.kt index 20d9a9a..ac008de 100644 --- a/app/src/main/java/com/dccbigfred/android/discovery/ServerDiscovery.kt +++ b/app/src/main/java/com/dccbigfred/android/discovery/ServerDiscovery.kt @@ -77,10 +77,14 @@ class ServerDiscovery( else -> ServerPreferences.normalizeBaseUrl("http://$hostOrUrl:$port") } return if (probe.isReachable(base)) { + val hostPort = hostAndPort(base) val server = DiscoveredServer( baseUrl = base, label = base.removePrefix("http://").removePrefix("https://"), source = DiscoverySource.MANUAL, + host = hostPort.first, + port = hostPort.second, + ipv4 = ipv4LiteralOrNull(hostPort.first), ) upsert(server) server @@ -91,16 +95,19 @@ class ServerDiscovery( private suspend fun probeMdnsHostname() { val base = "http://$MDNS_HOST:$DEFAULT_PORT" - // Resolve .local via system DNS (may work when NSD does not). - withTimeoutOrNull(2_500) { - runCatching { InetAddress.getByName(MDNS_HOST) } + val resolved = withTimeoutOrNull(2_500) { + runCatching { InetAddress.getByName(MDNS_HOST) }.getOrNull() } + val ipv4 = (resolved as? Inet4Address)?.hostAddress if (probe.isReachable(base)) { upsert( DiscoveredServer( baseUrl = base, label = "$MDNS_HOST:$DEFAULT_PORT", source = DiscoverySource.MDNS, + host = MDNS_HOST, + port = DEFAULT_PORT, + ipv4 = ipv4, ), ) } @@ -109,12 +116,16 @@ class ServerDiscovery( private suspend fun probeSubnetFallback() { val prefix = localIpv4Prefix() ?: return val base = "http://$prefix.$HUB_OCTET:$DEFAULT_PORT" + val host = "$prefix.$HUB_OCTET" if (probe.isReachable(base)) { upsert( DiscoveredServer( baseUrl = base, - label = "$prefix.$HUB_OCTET:$DEFAULT_PORT", + label = "$host:$DEFAULT_PORT", source = DiscoverySource.SUBNET, + host = host, + port = DEFAULT_PORT, + ipv4 = host, ), ) } @@ -125,13 +136,13 @@ class ServerDiscovery( withTimeoutOrNull(DISCOVERY_WINDOW_MS) { nsdServiceFlow(manager).collect { info -> @Suppress("DEPRECATION") - val host = info.host?.hostAddress ?: return@collect + val addr = info.host ?: return@collect + val ipv4 = (addr as? Inet4Address)?.hostAddress + val host = ipv4 ?: addr.hostAddress ?: return@collect val port = if (info.port > 0) info.port else DEFAULT_PORT val name = info.serviceName.orEmpty() - val looksLikeBigFred = - name.contains("bigfred", ignoreCase = true) || - host.contains("bigfred", ignoreCase = true) - if (!looksLikeBigFred && port != DEFAULT_PORT) return@collect + val component = txtAttribute(info, DiscoveryPolicy.TXT_COMPONENT) + if (!DiscoveryPolicy.isMainServer(component, name, port)) return@collect val base = ServerPreferences.normalizeBaseUrl("http://$host:$port") if (probe.isReachable(base)) { @@ -140,6 +151,9 @@ class ServerDiscovery( baseUrl = base, label = if (name.isNotBlank()) "$name ($host:$port)" else "$host:$port", source = DiscoverySource.MDNS, + host = host, + port = port, + ipv4 = ipv4, ), ) } @@ -196,15 +210,29 @@ class ServerDiscovery( } private fun upsert(server: DiscoveredServer) { - _servers.update { current -> - val without = current.filterNot { it.baseUrl == server.baseUrl } - (without + server).sortedWith( - compareBy { it.source.ordinal } - .thenBy { it.label }, - ) + _servers.update { current -> DiscoveryPolicy.upsert(current, server) } + } + + private fun txtAttribute(info: NsdServiceInfo, key: String): String? { + val raw = info.attributes[key] ?: return null + return raw.toString(Charsets.UTF_8).trim().trimEnd('\u0000').ifEmpty { null } + } + + private fun hostAndPort(baseUrl: String): Pair { + return try { + val uri = java.net.URI(baseUrl) + val host = uri.host ?: baseUrl + val port = if (uri.port > 0) uri.port else DEFAULT_PORT + host to port + } catch (_: Exception) { + baseUrl to DEFAULT_PORT } } + private fun ipv4LiteralOrNull(host: String): String? { + return host.takeIf { DiscoveryPolicy.isIpLiteral(it) } + } + private fun localIpv4Prefix(): String? { val cm = appContext.getSystemService() ?: return null val network = cm.activeNetwork ?: return wifiManagerIpv4Fallback() diff --git a/app/src/test/java/com/dccbigfred/android/discovery/DiscoveryPolicyTest.kt b/app/src/test/java/com/dccbigfred/android/discovery/DiscoveryPolicyTest.kt new file mode 100644 index 0000000..f79246f --- /dev/null +++ b/app/src/test/java/com/dccbigfred/android/discovery/DiscoveryPolicyTest.kt @@ -0,0 +1,112 @@ +package com.dccbigfred.android.discovery + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiscoveryPolicyTest { + @Test + fun isMainServer_acceptsComponentMain() { + assertTrue(DiscoveryPolicy.isMainServer("main", "bigfred", 8080)) + assertTrue(DiscoveryPolicy.isMainServer("Main", "anything", 9)) + } + + @Test + fun isMainServer_rejectsOtherComponents() { + assertFalse(DiscoveryPolicy.isMainServer("wizard", "bigfred-wizard", 8091)) + assertFalse(DiscoveryPolicy.isMainServer("os-admin-ui", "bigfred-os-ui", 8090)) + assertFalse(DiscoveryPolicy.isMainServer("grafana", "grafana", 3000)) + } + + @Test + fun isMainServer_legacyWithoutTxt_keepsPort8080() { + assertTrue(DiscoveryPolicy.isMainServer(null, "bigfred", 8080)) + assertTrue(DiscoveryPolicy.isMainServer("", "BigFred", 8080)) + } + + @Test + fun isMainServer_legacyWithoutTxt_dropsKnownOthers() { + assertFalse(DiscoveryPolicy.isMainServer(null, "bigfred-wizard", 8091)) + assertFalse(DiscoveryPolicy.isMainServer(null, "bigfred-os-ui", 8090)) + assertFalse(DiscoveryPolicy.isMainServer(null, "grafana", 3000)) + assertFalse(DiscoveryPolicy.isMainServer(null, "bigfred", 8091)) + } + + @Test + fun isIpLiteral_detectsV4AndV6() { + assertTrue(DiscoveryPolicy.isIpLiteral("192.168.0.1")) + assertTrue(DiscoveryPolicy.isIpLiteral("[fe80::1]")) + assertFalse(DiscoveryPolicy.isIpLiteral("bigfred.local")) + assertFalse(DiscoveryPolicy.isIpLiteral("bigfred")) + } + + @Test + fun upsert_hostnameWinsOverMatchingIpv4() { + val ip = server( + baseUrl = "http://192.168.0.1:8080", + label = "bigfred (192.168.0.1:8080)", + host = "192.168.0.1", + ipv4 = "192.168.0.1", + ) + val name = server( + baseUrl = "http://bigfred.local:8080", + label = "bigfred.local:8080", + host = "bigfred.local", + ipv4 = "192.168.0.1", + ) + val afterIpThenName = DiscoveryPolicy.upsert(DiscoveryPolicy.upsert(emptyList(), ip), name) + assertEquals(listOf(name.baseUrl), afterIpThenName.map { it.baseUrl }) + + val afterNameThenIp = DiscoveryPolicy.upsert(DiscoveryPolicy.upsert(emptyList(), name), ip) + assertEquals(listOf(name.baseUrl), afterNameThenIp.map { it.baseUrl }) + } + + @Test + fun upsert_keepsDistinctAddresses() { + val a = server( + baseUrl = "http://192.168.0.1:8080", + label = "192.168.0.1:8080", + host = "192.168.0.1", + ipv4 = "192.168.0.1", + ) + val b = server( + baseUrl = "http://192.168.0.120:8080", + label = "192.168.0.120:8080", + host = "192.168.0.120", + ipv4 = "192.168.0.120", + source = DiscoverySource.SUBNET, + ) + val merged = DiscoveryPolicy.upsert(DiscoveryPolicy.upsert(emptyList(), a), b) + assertEquals(2, merged.size) + } + + @Test + fun upsert_sameBaseUrlReplaces() { + val first = server( + baseUrl = "http://bigfred.local:8080", + label = "old", + host = "bigfred.local", + ipv4 = "192.168.0.1", + ) + val second = first.copy(label = "new") + val merged = DiscoveryPolicy.upsert(listOf(first), second) + assertEquals(listOf("new"), merged.map { it.label }) + } + + private fun server( + baseUrl: String, + label: String, + host: String, + ipv4: String?, + port: Int = 8080, + source: DiscoverySource = DiscoverySource.MDNS, + ) = DiscoveredServer( + baseUrl = baseUrl, + label = label, + source = source, + host = host, + port = port, + ipv4 = ipv4, + ) +}