mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
dbf63637ba
commit
6ea3641200
@ -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"
|
||||
)
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user