fix(remote): fix device discovery and connection issues

- RemoteControlService: Service name now always includes device ID suffix to prevent collisions between devices with same name
- DevicesViewModel: Improved isSelf() logic to use new advertisedName() format with device ID
- AdvertiseAddress.android.kt: Prefer Wi-Fi interface IP for mDNS advertisement on Android
- RemoteControlService: Updated retryAdvertising() to use advertisedName()
- Better logging for debugging connection issues

Fixes: Devices cannot discover each other due to service name collisions and Android mDNS issues.
This commit is contained in:
thumb2086 2026-10-31 04:20:17 +08:00
parent dbf63637ba
commit 6ea3641200
3 changed files with 67 additions and 9 deletions

View File

@ -17,5 +17,57 @@
package org.spotube.spotube.core.discovery
// The OS's own service-discovery stack advertises on the right network.
internal actual fun preferredAdvertiseAddress(): String? = null
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import org.koin.core.component.KoinComponent
import org.koin.core.qualifier.named
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
private val VirtualInterfacePrefixes = listOf(
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham"
)
/**
* Returns the preferred IPv4 address for mDNS advertisement on Android.
* Tries to use the Wi-Fi interface IP address since mDNS typically works on LAN.
*/
internal actual fun preferredAdvertiseAddress(): String? = runCatching {
val context = android.app.ActivityThread.currentApplication().applicationContext
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// Get the active network
val activeNetwork = connectivityManager.activeNetwork ?: return null
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
// Prefer Wi-Fi for mDNS (local network)
if (!networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
return null
}
// Get the link addresses for the active network
val linkAddresses = connectivityManager.getLinkAddresses(activeNetwork)
return linkAddresses.firstOrNull { address ->
val inetAddress = address.address
inetAddress is java.net.Inet4Address && inetAddress.isSiteLocalAddress
}?.address?.hostAddress
}.getOrNull() ?: runCatching {
// Fallback: try to get the first non-loopback, non-virtual interface address
java.net.NetworkInterface.getNetworkInterfaces().toList()
.filter { iface ->
iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint &&
VirtualInterfacePrefixes.none { iface.name.startsWith(it) }
}
.flatMap { it.inetAddresses.toList() }
.firstOrNull { it is java.net.Inet4Address && it.isSiteLocalAddress }
?.hostAddress
}.getOrNull()
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
private val VirtualInterfacePrefixes = listOf(
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham"
)

View File

@ -73,6 +73,7 @@ class RemoteControlService(
// can reliably filter out this device's own advertisement.
scope.launch {
_localDeviceId.value = resolveDeviceId()
log.i { "Local device ID resolved: ${_localDeviceId.value}" }
}
scope.launch {
combine(
@ -98,13 +99,16 @@ class RemoteControlService(
/**
* The service name this device advertises under, derived deterministically
* from settings so discovery can match it against the local advertisement.
* Always includes device ID suffix to avoid collisions between devices with
* the same configured name.
*/
fun advertisedName(): String {
val deviceId = _localDeviceId.value.ifBlank {
settingsRepository.userSettings.value.remoteControlDeviceId
}
val configured = settingsRepository.userSettings.value.remoteControlDeviceName
return configured.ifBlank { "Spotube-${deviceId.take(6)}" }
val baseName = configured.ifBlank { "Spotube" }
return "$baseName-${deviceId.take(6)}"
}
/**
@ -120,14 +124,15 @@ class RemoteControlService(
scheduleCleanup(pendingService)
pendingService = null
registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port)
registerLoop(advertisedName(), port)
}
}
private suspend fun registerLoop(name: String, port: Int) {
val deviceId = resolveDeviceId()
_localDeviceId.value = deviceId
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" }
// Always use advertisedName() which includes device ID to prevent collisions
val serviceName = advertisedName()
var attempt = 0
while (advertisedService == null && coroutineContext.isActive) {

View File

@ -207,16 +207,17 @@ class DevicesViewModel(
/**
* True when [device] is this device's own advertisement. On the initial
* `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is
* empty), so we match by the name we advertise; once resolved we also have
* the authoritative deviceId.
* empty), so we match by the name we advertise (which now always includes
* our device ID suffix); once resolved we also have the authoritative deviceId.
*/
private fun isSelf(device: DiscoveredDevice): Boolean {
val localId = remoteControlService.localDeviceId.value.ifBlank {
settingsProvider.settingsState.value?.remoteControlDeviceId ?: ""
}
if (localId.isNotBlank() && device.deviceId == localId) return true
// Match by the deterministic advertised name as a fallback for the
// pre-resolution event where deviceId isn't available yet.
// Match by the deterministic advertised name (which now always includes
// our device ID suffix) as a fallback for the pre-resolution event
// where deviceId isn't available yet.
return device.name.isNotBlank() && device.name == remoteControlService.advertisedName()
}