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

NetworkInterfaceResolver.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import android.content.Context
4import android.net.ConnectivityManager
5import android.net.LinkProperties
6import android.net.Network
7import android.net.NetworkCapabilities
8import android.os.Build
9import android.util.Log
10import java.net.NetworkInterface
11
12/**
13 * Determines which network interface(s) jUPnP should bind to for SSDP multicast.
14 *
15 * Why this exists: jUPnP's [org.jupnp.transport.impl.NetworkAddressFactoryImpl] enumerates
16 * every interface returned by [NetworkInterface.getNetworkInterfaces] and binds multicast
17 * sockets to all of them that aren't loopback, ppp*, vmnet*, vnic*, or *virtual*. On modern
18 * Android phones that filter is dangerously incomplete:
19 *
20 * - **VPN apps** (NetGuard, ProtonVPN, Tailscale, WireGuard) create `tun*` interfaces.
21 * NetGuard in particular runs as a per-app firewall using Android's VPN API; even when
22 * every app is "allow-listed" for both WiFi and Mobile, the tun interface still exists
23 * and jUPnP's default factory will bind SSDP multicast to it. SSDP packets sent through
24 * tun0 never make it out to the LAN, so no UPnP servers are ever discovered.
25 * - **Cellular** interfaces (`rmnet*`, `ccmni*`, `pdp_ip*`) — the UPnP server is on the
26 * user's home LAN; binding multicast to cellular is wasted effort at best, and at worst
27 * jUPnP picks cellular as its primary interface and sends `M-SEARCH` only via cellular.
28 *
29 * The fix is jUPnP's `org.jupnp.network.useInterfaces` system property: a comma-separated
30 * list of interfaces to bind exclusively. We set it before the UPnP service is bound,
31 * preferring the interface that Android's [ConnectivityManager] reports as the active
32 * **WiFi** transport.
33 *
34 * **Read-once semantics**: jUPnP reads this property when it constructs
35 * NetworkAddressFactoryImpl, which happens during the first bind to AndroidUpnpServiceImpl.
36 * Setting it again later has no effect on the running stack — it only affects subsequent
37 * starts. We accept this for now; the worst case is the user reconnects to a different
38 * network and has to background+foreground the app to refresh discovery, which mirrors
39 * the behavior of every other Android UPnP client (BubbleUPnP, VLC) on a network change.
40 */
41object NetworkInterfaceResolver {
42
43 private const val TAG = "EarhornUpnp"
44 private const val PROP = "org.jupnp.network.useInterfaces"
45
46 /** Names of interface prefixes we never want jUPnP to bind to.
47 *
48 * Order roughly by how common they are in the wild; matching is by `startsWith`. */
49 private val BLOCKED_PREFIXES = listOf(
50 "tun", // VPN apps (NetGuard, ProtonVPN, Tailscale, WireGuard, *most* VPNs)
51 "rmnet", // Cellular on Qualcomm-based devices
52 "ccmni", // Cellular on MediaTek-based devices
53 "pdp_ip", // Cellular on older devices
54 "ppp", // PPP — already filtered by jUPnP, kept here for clarity
55 "dummy", // Kernel dummy interfaces
56 "ifb", // Intermediate Functional Block (QoS)
57 "ip6tnl", // IPv6 tunnel
58 "sit", // IPv6-in-IPv4 tunnel
59 )
60
61 /** Names that strongly suggest the interface is real LAN/WiFi. */
62 private val PREFERRED_PREFIXES = listOf(
63 "wlan", // WiFi on most Android devices
64 "eth", // Wired Ethernet (Android TV, dev boards)
65 "en", // Wired Ethernet on some devices
66 )
67
68 /**
69 * Sets the jUPnP system property based on the current network state.
70 *
71 * **Conservative**: only sets the property when ConnectivityManager
72 * gives a high-confidence WiFi/Ethernet interface name. Falls back to
73 * NOT restricting (clears the property) rather than risk binding to
74 * the wrong interface and breaking discovery on devices we can't
75 * confidently classify.
76 *
77 * Logs everything to logcat tag `EarhornUpnp` so failures are debuggable
78 * via `adb logcat -s EarhornUpnp`.
79 */
80 fun applyToSystemProperties(context: Context) {
81 val resolved = highConfidenceWifiInterface(context)
82 if (resolved != null) {
83 System.setProperty(PROP, resolved)
84 Log.i(TAG, "Restricting jUPnP to interface: $resolved")
85 } else {
86 // Could not determine an active WiFi/Ethernet interface with
87 // high confidence. Rather than guess and risk picking tun0 or
88 // rmnet0, clear any prior setting and let jUPnP's default
89 // interface enumeration apply. This means discovery may still
90 // bind to extra interfaces (cellular, VPN tun) but the
91 // wlan-bound one will at least be in the set.
92 System.clearProperty(PROP)
93 logAvailableInterfaces()
94 Log.w(TAG, "No high-confidence WiFi/Ethernet interface — using jUPnP defaults")
95 }
96 }
97
98 /**
99 * Strategy: ask Android's ConnectivityManager for the active network
100 * marked as TRANSPORT_WIFI or TRANSPORT_ETHERNET (and explicitly NOT
101 * TRANSPORT_VPN), then read its [LinkProperties.getInterfaceName].
102 *
103 * This is the only method we trust enough to use as the basis for
104 * setting `org.jupnp.network.useInterfaces` — interface name guessing
105 * by prefix (wlan*, eth*) is too risky on devices with non-standard
106 * names (some Samsung, MTK, fork ROMs).
107 *
108 * Returns null if the active wifi network can't be found, the
109 * interface name is null/empty, or the interface name starts with a
110 * blocked prefix. The caller treats null as "don't restrict, let
111 * jUPnP's defaults apply."
112 */
113 private fun highConfidenceWifiInterface(context: Context): String? {
114 val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
115 ?: return null
116 val networks: Array<Network> = runCatching { cm.allNetworks }.getOrNull() ?: return null
117 for (network in networks) {
118 // Maintainer, 2026-10-29: getNetworkCapabilities throws SecurityException
119 // on some Android 11 builds; called bare from MainActivity and
120 // PlaybackService.onCreate it killed the process. Skip that network.
121 val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: continue
122 val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
123 caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
124 val isVpn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
125 caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
126 } else false
127 if (!isWifi || isVpn) continue
128
129 // Maintainer, 2026-10-29: same SecurityException path as getNetworkCapabilities.
130 val link: LinkProperties = runCatching { cm.getLinkProperties(network) }.getOrNull() ?: continue
131 val name = link.interfaceName ?: continue
132 if (name.isBlank()) continue
133 if (BLOCKED_PREFIXES.any { name.startsWith(it) }) {
134 Log.w(TAG, "Skipping interface '$name' — name starts with a blocked prefix")
135 continue
136 }
137 // Sanity check — must have at least one usable IPv4 address.
138 val hasIpv4 = link.linkAddresses.any { la -> la.address.address.size == 4 }
139 if (!hasIpv4) {
140 Log.w(TAG, "Skipping interface '$name' — no IPv4 address")
141 continue
142 }
143 return name
144 }
145 return null
146 }
147
148 /** Diagnostic dump of every interface present + the system's view of it.
149 * Helps when discovery silently fails — user can grep logcat. */
150 private fun logAvailableInterfaces() {
151 runCatching {
152 val ifaces = NetworkInterface.getNetworkInterfaces() ?: return
153 for (iface in ifaces.toList()) {
154 val flags = buildString {
155 append("up=${iface.isUp},")
156 append("loop=${iface.isLoopback},")
157 append("vir=${iface.isVirtual},")
158 append("p2p=${iface.isPointToPoint}")
159 }
160 val addrs = iface.inetAddresses.toList().joinToString(",") { it.hostAddress ?: "?" }
161 Log.i(TAG, " iface=${iface.name} [$flags] addrs=[$addrs]")
162 }
163 }
164 }
165}