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]")
}
}
}
}