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 package org.spotube.spotube.core.discovery
// The OS's own service-discovery stack advertises on the right network. import android.content.Context
internal actual fun preferredAdvertiseAddress(): String? = null 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. // can reliably filter out this device's own advertisement.
scope.launch { scope.launch {
_localDeviceId.value = resolveDeviceId() _localDeviceId.value = resolveDeviceId()
log.i { "Local device ID resolved: ${_localDeviceId.value}" }
} }
scope.launch { scope.launch {
combine( combine(
@ -98,13 +99,16 @@ class RemoteControlService(
/** /**
* The service name this device advertises under, derived deterministically * The service name this device advertises under, derived deterministically
* from settings so discovery can match it against the local advertisement. * 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 { fun advertisedName(): String {
val deviceId = _localDeviceId.value.ifBlank { val deviceId = _localDeviceId.value.ifBlank {
settingsRepository.userSettings.value.remoteControlDeviceId settingsRepository.userSettings.value.remoteControlDeviceId
} }
val configured = settingsRepository.userSettings.value.remoteControlDeviceName 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) scheduleCleanup(pendingService)
pendingService = null pendingService = null
registerJob = scope.launch { registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port) registerLoop(advertisedName(), port)
} }
} }
private suspend fun registerLoop(name: String, port: Int) { private suspend fun registerLoop(name: String, port: Int) {
val deviceId = resolveDeviceId() val deviceId = resolveDeviceId()
_localDeviceId.value = deviceId _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 var attempt = 0
while (advertisedService == null && coroutineContext.isActive) { 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 * 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 * `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 * empty), so we match by the name we advertise (which now always includes
* the authoritative deviceId. * our device ID suffix); once resolved we also have the authoritative deviceId.
*/ */
private fun isSelf(device: DiscoveredDevice): Boolean { private fun isSelf(device: DiscoveredDevice): Boolean {
val localId = remoteControlService.localDeviceId.value.ifBlank { val localId = remoteControlService.localDeviceId.value.ifBlank {
settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" settingsProvider.settingsState.value?.remoteControlDeviceId ?: ""
} }
if (localId.isNotBlank() && device.deviceId == localId) return true if (localId.isNotBlank() && device.deviceId == localId) return true
// Match by the deterministic advertised name as a fallback for the // Match by the deterministic advertised name (which now always includes
// pre-resolution event where deviceId isn't available yet. // 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() return device.name.isNotBlank() && device.name == remoteControlService.advertisedName()
} }