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
|
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"
|
||||||
|
)
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user