EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

NetworkInterfaceResolver.kt

package dev.stave.upnp

import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import android.util.Log
import java.net.NetworkInterface

/**
 * Determines which network interface(s) jUPnP should bind to for SSDP multicast.
 *
 * Why this exists: jUPnP's [org.jupnp.transport.impl.NetworkAddressFactoryImpl] enumerates
 * every interface returned by [NetworkInterface.getNetworkInterfaces] and binds multicast
 * sockets to all of them that aren't loopback, ppp*, vmnet*, vnic*, or *virtual*. On modern
 * Android phones that filter is dangerously incomplete:
 *
 *  - **VPN apps** (NetGuard, ProtonVPN, Tailscale, WireGuard) create `tun*` interfaces.
 *    NetGuard in particular runs as a per-app firewall using Android's VPN API; even when
 *    every app is "allow-listed" for both WiFi and Mobile, the tun interface still exists
 *    and jUPnP's default factory will bind SSDP multicast to it. SSDP packets sent through
 *    tun0 never make it out to the LAN, so no UPnP servers are ever discovered.
 *  - **Cellular** interfaces (`rmnet*`, `ccmni*`, `pdp_ip*`) — the UPnP server is on the
 *    user's home LAN; binding multicast to cellular is wasted effort at best, and at worst
 *    jUPnP picks cellular as its primary interface and sends `M-SEARCH` only via cellular.
 *
 * The fix is jUPnP's `org.jupnp.network.useInterfaces` system property: a comma-separated
 * list of interfaces to bind exclusively. We set it before the UPnP service is bound,
 * preferring the interface that Android's [ConnectivityManager] reports as the active
 * **WiFi** transport.
 *
 * **Read-once semantics**: jUPnP reads this property when it constructs
 * NetworkAddressFactoryImpl, which happens during the first bind to AndroidUpnpServiceImpl.
 * Setting it again later has no effect on the running stack — it only affects subsequent
 * starts. We accept this for now; the worst case is the user reconnects to a different
 * network and has to background+foreground the app to refresh discovery, which mirrors
 * the behavior of every other Android UPnP client (BubbleUPnP, VLC) on a network change.
 */
object NetworkInterfaceResolver {

    private const val TAG = "EarhornUpnp"
    private const val PROP = "org.jupnp.network.useInterfaces"

    /** Names of interface prefixes we never want jUPnP to bind to.
     *
     *  Order roughly by how common they are in the wild; matching is by `startsWith`. */
    private val BLOCKED_PREFIXES = listOf(
        "tun",      // VPN apps (NetGuard, ProtonVPN, Tailscale, WireGuard, *most* VPNs)
        "rmnet",    // Cellular on Qualcomm-based devices
        "ccmni",    // Cellular on MediaTek-based devices
        "pdp_ip",   // Cellular on older devices
        "ppp",      // PPP — already filtered by jUPnP, kept here for clarity
        "dummy",    // Kernel dummy interfaces
        "ifb",      // Intermediate Functional Block (QoS)
        "ip6tnl",   // IPv6 tunnel
        "sit",      // IPv6-in-IPv4 tunnel
    )

    /** Names that strongly suggest the interface is real LAN/WiFi. */
    private val PREFERRED_PREFIXES = listOf(
        "wlan",     // WiFi on most Android devices
        "eth",      // Wired Ethernet (Android TV, dev boards)
        "en",       // Wired Ethernet on some devices
    )

    /**
     * Sets the jUPnP system property based on the current network state.
     *
     * **Conservative**: only sets the property when ConnectivityManager
     * gives a high-confidence WiFi/Ethernet interface name. Falls back to
     * NOT restricting (clears the property) rather than risk binding to
     * the wrong interface and breaking discovery on devices we can't
     * confidently classify.
     *
     * Logs everything to logcat tag `EarhornUpnp` so failures are debuggable
     * via `adb logcat -s EarhornUpnp`.
     */
    fun applyToSystemProperties(context: Context) {
        val resolved = highConfidenceWifiInterface(context)
        if (resolved != null) {
            System.setProperty(PROP, resolved)
            Log.i(TAG, "Restricting jUPnP to interface: $resolved")
        } else {
            // Could not determine an active WiFi/Ethernet interface with
            // high confidence. Rather than guess and risk picking tun0 or
            // rmnet0, clear any prior setting and let jUPnP's default
            // interface enumeration apply. This means discovery may still
            // bind to extra interfaces (cellular, VPN tun) but the
            // wlan-bound one will at least be in the set.
            System.clearProperty(PROP)
            logAvailableInterfaces()
            Log.w(TAG, "No high-confidence WiFi/Ethernet interface — using jUPnP defaults")
        }
    }

    /**
     * Strategy: ask Android's ConnectivityManager for the active network
     * marked as TRANSPORT_WIFI or TRANSPORT_ETHERNET (and explicitly NOT
     * TRANSPORT_VPN), then read its [LinkProperties.getInterfaceName].
     *
     * This is the only method we trust enough to use as the basis for
     * setting `org.jupnp.network.useInterfaces` — interface name guessing
     * by prefix (wlan*, eth*) is too risky on devices with non-standard
     * names (some Samsung, MTK, fork ROMs).
     *
     * Returns null if the active wifi network can't be found, the
     * interface name is null/empty, or the interface name starts with a
     * blocked prefix. The caller treats null as "don't restrict, let
     * jUPnP's defaults apply."
     */
    private fun highConfidenceWifiInterface(context: Context): String? {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
            ?: return null
        val networks: Array<Network> = runCatching { cm.allNetworks }.getOrNull() ?: return null
        for (network in networks) {
            // Maintainer, 2026-10-29: getNetworkCapabilities throws SecurityException
            // on some Android 11 builds; called bare from MainActivity and
            // PlaybackService.onCreate it killed the process. Skip that network.
            val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: continue
            val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
                         caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
            val isVpn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
            } else false
            if (!isWifi || isVpn) continue

            // Maintainer, 2026-10-29: same SecurityException path as getNetworkCapabilities.
            val link: LinkProperties = runCatching { cm.getLinkProperties(network) }.getOrNull() ?: continue
            val name = link.interfaceName ?: continue
            if (name.isBlank()) continue
            if (BLOCKED_PREFIXES.any { name.startsWith(it) }) {
                Log.w(TAG, "Skipping interface '$name' — name starts with a blocked prefix")
                continue
            }
            // Sanity check — must have at least one usable IPv4 address.
            val hasIpv4 = link.linkAddresses.any { la -> la.address.address.size == 4 }
            if (!hasIpv4) {
                Log.w(TAG, "Skipping interface '$name' — no IPv4 address")
                continue
            }
            return name
        }
        return null
    }

    /** Diagnostic dump of every interface present + the system's view of it.
     *  Helps when discovery silently fails — user can grep logcat. */
    private fun logAvailableInterfaces() {
        runCatching {
            val ifaces = NetworkInterface.getNetworkInterfaces() ?: return
            for (iface in ifaces.toList()) {
                val flags = buildString {
                    append("up=${iface.isUp},")
                    append("loop=${iface.isLoopback},")
                    append("vir=${iface.isVirtual},")
                    append("p2p=${iface.isPointToPoint}")
                }
                val addrs = iface.inetAddresses.toList().joinToString(",") { it.hostAddress ?: "?" }
                Log.i(TAG, "  iface=${iface.name} [$flags] addrs=[$addrs]")
            }
        }
    }
}