Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<DiscoveredServer>,
incoming: DiscoveredServer,
): List<DiscoveredServer> {
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>): DiscoveredServer {
return candidates.minWith(
compareBy<DiscoveredServer> { if (isIpLiteral(it.host)) 1 else 0 }
.thenBy { it.source.ordinal }
.thenBy { it.label },
)
}

private fun sort(rows: List<DiscoveredServer>): List<DiscoveredServer> {
return rows.sortedWith(
compareBy<DiscoveredServer> { it.source.ordinal }
.thenBy { if (isIpLiteral(it.host)) 1 else 0 }
.thenBy { it.label },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
),
)
}
Expand All @@ -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,
),
)
}
Expand All @@ -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)) {
Expand All @@ -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,
),
)
}
Expand Down Expand Up @@ -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<DiscoveredServer> { 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<String, Int> {
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<ConnectivityManager>() ?: return null
val network = cm.activeNetwork ?: return wifiManagerIpv4Fallback()
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading