diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt index 2f73a824..1cb5033d 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt @@ -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" +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt index c0c19ae6..749b76d3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index 19f365ee..8d51f2e9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -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() }