phase 2: add mDNS LAN discovery and server picker integration

This commit is contained in:
2026-03-03 08:00:31 -06:00
parent a350aa3982
commit b3f654fd49
5 changed files with 250 additions and 0 deletions
+1
View File
@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
@@ -52,6 +52,7 @@ class MainActivity : AppCompatActivity() {
private var initialLoadComplete = false
private var networkCallbackRegistered = false
private var isInForeground = false
private var mdnsScanner: NsdRelayTvScanner? = null
private val heartbeatRunnable = Runnable { runHeartbeat() }
@@ -153,6 +154,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
uiHandler.removeCallbacksAndMessages(null)
unregisterNetworkCallback()
mdnsScanner?.stop()
super.onDestroy()
}
@@ -298,12 +300,41 @@ class MainActivity : AppCompatActivity() {
networkCallbackRegistered = false
}
private fun upsertDiscoveredServer(server: DiscoveredRelayTvServer): RelayHost {
val normalized = HostStore.normalizeBaseUrl(server.baseUrl) ?: server.baseUrl
val hosts = HostStore.loadHosts(this)
val existing = hosts.firstOrNull {
HostStore.normalizeBaseUrl(it.baseUrl) == normalized
}
return if (existing != null) {
existing
} else {
HostStore.create(this, server.name.ifBlank { "RelayTV" }, normalized)
}
}
private fun showDiscoveredServersDialog(
servers: List<DiscoveredRelayTvServer>,
onPick: (DiscoveredRelayTvServer) -> Unit,
) {
val labels = servers.map { "${it.name}${it.baseUrl}" }.toTypedArray()
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.discovered_servers))
.setItems(labels) { _, which ->
val chosen = servers.getOrNull(which) ?: return@setItems
onPick(chosen)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showServerPicker(force: Boolean = false) {
val view = LayoutInflater.from(this).inflate(R.layout.dialog_server_picker, null)
val list = view.findViewById<ListView>(R.id.listServers)
val btnAdd = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnAdd)
val btnEdit = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnEdit)
val btnRemove = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnRemove)
val btnDiscover = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnDiscover)
fun refresh(selectionId: String? = HostStore.getActiveHostId(this)) {
val hosts = HostStore.loadHosts(this)
@@ -317,6 +348,7 @@ class MainActivity : AppCompatActivity() {
}
refresh()
btnDiscover.text = getString(R.string.scan_lan)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.select_server))
@@ -341,6 +373,47 @@ class MainActivity : AppCompatActivity() {
d.dismiss()
}
.create()
dialog.setOnDismissListener {
mdnsScanner?.stop()
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
}
btnDiscover.setOnClickListener {
btnDiscover.isEnabled = false
btnDiscover.text = getString(R.string.scanning_lan)
val scanner = mdnsScanner ?: NsdRelayTvScanner(this).also { mdnsScanner = it }
scanner.scan(callback = object : NsdRelayTvScanner.Callback {
override fun onUpdate(servers: List<DiscoveredRelayTvServer>) = Unit
override fun onFinished(servers: List<DiscoveredRelayTvServer>) {
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
if (!dialog.isShowing) return
if (servers.isEmpty()) {
Toast.makeText(this@MainActivity, getString(R.string.discovery_none), Toast.LENGTH_SHORT).show()
return
}
showDiscoveredServersDialog(servers) { selected ->
val host = upsertDiscoveredServer(selected)
HostStore.setActiveHostId(this@MainActivity, host.id)
toolbar.subtitle = host.name
refresh(host.id)
loadServerBase(host.baseUrl, forcePickerOnFailure = false, manualRefresh = false)
dialog.dismiss()
}
}
override fun onError(message: String) {
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
if (!dialog.isShowing) return
val msg = message.ifBlank { getString(R.string.discovery_failed) }
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show()
}
})
}
list.setOnItemClickListener { _, _, position, _ ->
val hosts = HostStore.loadHosts(this)
@@ -0,0 +1,163 @@
package pro.relaytv
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.os.Handler
import android.os.Looper
data class DiscoveredRelayTvServer(
val name: String,
val baseUrl: String,
val path: String,
)
class NsdRelayTvScanner(context: Context) {
private val appContext = context.applicationContext
private val nsdManager = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager
private val uiHandler = Handler(Looper.getMainLooper())
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var timeoutRunnable: Runnable? = null
private var multicastLock: WifiManager.MulticastLock? = null
companion object {
private const val SERVICE_TYPE = "_relaytv._tcp."
}
interface Callback {
fun onUpdate(servers: List<DiscoveredRelayTvServer>)
fun onFinished(servers: List<DiscoveredRelayTvServer>)
fun onError(message: String)
}
fun scan(timeoutMs: Long = 8_000L, callback: Callback) {
stop()
val found = linkedMapOf<String, DiscoveredRelayTvServer>()
var finished = false
fun sortedServers(): List<DiscoveredRelayTvServer> =
found.values.sortedWith(compareBy({ it.name.lowercase() }, { it.baseUrl }))
fun emitUpdate() {
val snapshot = sortedServers()
uiHandler.post { callback.onUpdate(snapshot) }
}
fun emitError(message: String) {
uiHandler.post { callback.onError(message) }
}
fun finish() {
if (finished) return
finished = true
val snapshot = sortedServers()
uiHandler.post { callback.onFinished(snapshot) }
}
fun upsertResolved(info: NsdServiceInfo) {
val attributes = info.attributes
val serviceTag = attributes?.get("service")?.toUtf8()?.lowercase()
if (!serviceTag.isNullOrBlank() && serviceTag != "relaytv") return
val ip = (info.host?.hostAddress ?: "").trim().substringBefore('%')
if (ip.isBlank()) return
val host = if (ip.contains(":")) "[$ip]" else ip
val base = HostStore.normalizeBaseUrl("http://$host:${info.port}") ?: return
val path = attributes?.get("path")?.toUtf8()?.ifBlank { "/ui" } ?: "/ui"
val name = info.serviceName?.trim().orEmpty().ifBlank { "RelayTV" }
val server = DiscoveredRelayTvServer(name = name, baseUrl = base, path = path)
found[base] = server
emitUpdate()
}
val listener = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
emitError("mDNS discovery failed to start ($errorCode)")
stop()
finish()
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
emitError("mDNS discovery failed to stop cleanly ($errorCode)")
stop()
finish()
}
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onDiscoveryStopped(serviceType: String) {
finish()
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (!serviceInfo.serviceType.equals(SERVICE_TYPE, ignoreCase = true)) return
nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
uiHandler.post { upsertResolved(serviceInfo) }
}
})
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val ip = (serviceInfo.host?.hostAddress ?: "").trim().substringBefore('%')
if (ip.isBlank()) return
val host = if (ip.contains(":")) "[$ip]" else ip
val base = HostStore.normalizeBaseUrl("http://$host:${serviceInfo.port}") ?: return
if (found.remove(base) != null) {
emitUpdate()
}
}
}
discoveryListener = listener
acquireMulticastLock()
val started = runCatching {
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
}.isSuccess
if (!started) {
emitError("Unable to start LAN discovery.")
stop()
finish()
return
}
timeoutRunnable = Runnable { stop() }.also { uiHandler.postDelayed(it, timeoutMs) }
}
fun stop() {
timeoutRunnable?.let { uiHandler.removeCallbacks(it) }
timeoutRunnable = null
discoveryListener?.let { listener ->
runCatching { nsdManager.stopServiceDiscovery(listener) }
}
discoveryListener = null
releaseMulticastLock()
}
private fun acquireMulticastLock() {
if (multicastLock?.isHeld == true) return
val wifi = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return
multicastLock = wifi.createMulticastLock("relaytv-mdns-scan").apply {
setReferenceCounted(false)
runCatching { acquire() }
}
}
private fun releaseMulticastLock() {
val lock = multicastLock ?: return
runCatching {
if (lock.isHeld) lock.release()
}
multicastLock = null
}
}
private fun ByteArray.toUtf8(): String = toString(Charsets.UTF_8).trim()
@@ -51,4 +51,12 @@
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDiscover"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/scan_lan" />
</LinearLayout>
+5
View File
@@ -9,4 +9,9 @@
<string name="server_name">Name</string>
<string name="server_url">Base URL</string>
<string name="reload">Reload</string>
<string name="scan_lan">Scan LAN</string>
<string name="scanning_lan">Scanning LAN…</string>
<string name="discovered_servers">Discovered RelayTV Servers</string>
<string name="discovery_none">No RelayTV servers found on your network.</string>
<string name="discovery_failed">LAN discovery failed.</string>
</resources>