netdiag-app/native-plugin/NetDiagScannerPlugin.kt
Eduard Wisch 1a0f1dc5ca IP-Scan: Geräte deutlich umfassender erkennen
- NetBIOS-Namensabfrage (UDP 137) je Host integriert
- mdnsScan: neue Plugin-Methode, mDNS/Bonjour via NsdManager — findet
  Drucker, Kameras, Chromecast, AirPlay; liefert Namen + Diensttypen
- Quick-Port-Probe (22/80/443/554/9100 …) speist eine deviceType-Heuristik
  (Kamera, Drucker, Router, Switch, NAS, Wallbox, Server …)
- OUI-Vendor-Tabelle von 6 auf ~150 kuratierte Einträge erweitert
- ipscan.ts führt IP-Scan + mDNS pro IP zusammen, mDNS-only-Geräte ergänzt
- neue DeviceCard-Komponente: zeigt Geräteart-Badge, offene Ports,
  mDNS-Dienste, mac, NetBIOS-Name; ersetzt die Inline-Geräteliste
- upsertDevice überschreibt vorhandene Daten nicht mehr mit undefined
  (Favorit/eigener Name bleiben bei magerem Re-Scan erhalten)
- Manifest: CHANGE_WIFI_MULTICAST_STATE für die mDNS-Suche

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:42:25 +02:00

1015 lines
44 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package de.data_it_solution.netdiag
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.FileReader
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* NetDiagScanner — natives Scan-Plugin der NetDiag-App.
*
* Der WebView kann keine Raw-Sockets/ICMP/ARP. Diese Klasse führt die
* eigentlichen Netzwerk-Messungen durch und wird vom TS-Wrapper
* (src/lib/scanner.ts) über `registerPlugin('NetDiagScanner')` angesprochen.
*
* Integration: Datei nach
* android/app/src/main/java/de/data_it_solution/netdiag/
* kopieren und in MainActivity registrieren:
* registerPlugin(NetDiagScannerPlugin::class.java)
*/
@CapacitorPlugin(
name = "NetDiagScanner",
permissions = [
Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION])
]
)
class NetDiagScannerPlugin : Plugin() {
private val io = CoroutineScope(Dispatchers.IO)
private val stressRuns = ConcurrentHashMap<String, StressRun>()
/* --------------------------------------------------------------------- */
/* Subnetz / lokale Netzwerkinfo */
/* --------------------------------------------------------------------- */
@PluginMethod
fun getLocalSubnet(call: PluginCall) {
io.launch {
try {
var ip = ""
var prefix = 0
var gateway = ""
// 1. Aktives Netz (WLAN ODER Ethernet) über LinkProperties —
// liefert das ECHTE Präfix, nicht pauschal /24.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val lp = cm.activeNetwork?.let { cm.getLinkProperties(it) }
if (lp != null) {
for (la in lp.linkAddresses) {
val a = la.address
if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) {
ip = a.hostAddress ?: ""
prefix = la.prefixLength
break
}
}
for (route in lp.routes) {
val gw = route.gateway
if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) {
gateway = gw.hostAddress ?: ""
break
}
}
}
}
// 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer)
if (ip.isEmpty() || prefix == 0) {
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
if (dhcp != null) {
if (ip.isEmpty() && dhcp.ipAddress != 0) ip = intToIp(dhcp.ipAddress)
if (prefix == 0 && dhcp.netmask != 0) prefix = Integer.bitCount(dhcp.netmask)
if (gateway.isEmpty() && dhcp.gateway != 0) gateway = intToIp(dhcp.gateway)
}
}
// 3. Letzter Fallback
if (ip.isEmpty()) ip = firstLocalIpv4()
if (prefix !in 1..32) prefix = 24
// Netzadresse aus IP + Präfix berechnen
val ipInt = ipv4ToInt(ip) ?: 0
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = intToIpv4(ipInt and mask)
resolve(call, JSObject()
.put("subnet", "$network/$prefix")
.put("ip", ip)
.put("gateway", gateway))
} catch (e: Exception) {
call.reject("getLocalSubnet: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* IP-Scan: Geräte im Subnetz finden */
/* --------------------------------------------------------------------- */
@PluginMethod
fun ipScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
val alive = withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
val ip = intToIpv4(ipInt)
if (InetAddress.getByName(ip).isReachable(350)) ip else null
}
}.awaitAll().filterNotNull()
}
val arp = readArpTable()
// Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name,
// Quick-Port-Probe (für die Geräteart-Heuristik).
val enriched = withContext(Dispatchers.IO) {
alive.map { ip ->
async {
val hostname = try {
val n = InetAddress.getByName(ip).canonicalHostName
if (n != ip) n else ""
} catch (_: Exception) { "" }
EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip))
}
}.awaitAll()
}
val devices = JSArray()
for (h in enriched) {
val dev = JSObject().put("ip", h.ip)
val mac = arp[h.ip]
val vendor = mac?.let { ouiVendor(it) } ?: ""
if (mac != null) dev.put("mac", mac)
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios)
if (h.openPorts.isNotEmpty()) {
val pa = JSArray()
h.openPorts.sorted().forEach { pa.put(it) }
dev.put("openPorts", pa)
}
val nameHint = h.hostname.ifEmpty { h.netbios ?: "" }
val type = guessDeviceType(vendor, nameHint, h.openPorts)
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
resolve(call, JSObject().put("devices", devices))
} catch (e: Exception) {
call.reject("ipScan: ${e.message}")
}
}
}
/** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */
private data class EnrichedHost(
val ip: String,
val hostname: String,
val netbios: String?,
val openPorts: List<Int>,
)
/**
* Schneller TCP-Connect-Test auf einige Schlüsselports — speist die
* Geräteart-Heuristik (z.B. 554 → Kamera, 9100 → Drucker). 500 ms Timeout.
*/
private suspend fun quickPortProbe(ip: String): List<Int> {
val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100)
return withContext(Dispatchers.IO) {
probe.map { port ->
async {
try {
Socket().use { it.connect(InetSocketAddress(ip, port), 500) }
port
} catch (_: Exception) {
null
}
}
}.awaitAll().filterNotNull()
}
}
/**
* NetBIOS-Namensabfrage (NBSTAT, UDP 137) — liefert den Workstation-Namen
* vieler Windows-Rechner, NAS-Geräte und Kameras. Kein Root nötig.
*/
private fun netbiosName(ip: String): String? {
// NBSTAT-„Node Status Request" für den Wildcard-Namen "*" (50 Byte).
val query = byteArrayOf(
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x20,
0x43, 0x4B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x00, 0x00, 0x21, 0x00, 0x01,
)
return try {
DatagramSocket().use { sock ->
sock.soTimeout = 600
sock.send(DatagramPacket(query, query.size, InetAddress.getByName(ip), 137))
val buf = ByteArray(512)
val resp = DatagramPacket(buf, buf.size)
sock.receive(resp)
val data = resp.data
if (resp.length < 57) return null
val numNames = data[56].toInt() and 0xFF
for (i in 0 until numNames) {
val base = 57 + i * 18
if (base + 18 > resp.length) break
val suffix = data[base + 15].toInt() and 0xFF
val isGroup = (data[base + 16].toInt() and 0x80) != 0
// Suffix 0x00 + kein Gruppen-Flag = der eigentliche Gerätename
if (suffix == 0x00 && !isGroup) {
val name = String(data, base, 15, Charsets.US_ASCII).trim()
if (name.isNotEmpty()) return name
}
}
null
}
} catch (_: Exception) {
null
}
}
/**
* Geräteart aus Hersteller, Name und offenen Ports schätzen.
* Best-Effort-Heuristik — leerer String, wenn nichts Eindeutiges erkennbar.
*/
private fun guessDeviceType(vendor: String, name: String, ports: List<Int>): String {
val v = vendor.lowercase()
val n = name.lowercase()
// 1. Eindeutige Hersteller
if (v.contains("axis") || v.contains("hikvision") || v.contains("dahua")) return "Kamera"
if (v.contains("avm")) return "Router"
if (v.contains("sonos")) return "Lautsprecher"
if (v.contains("synology") || v.contains("qnap")) return "NAS"
if (v.contains("raspberry")) return "Raspberry Pi"
if (v.contains("espressif")) return "IoT-Gerät"
// 2. Namensmuster
if (n.contains("camera") || n.contains("kamera") || n.contains("ipcam") ||
n.contains("nvr") || n.contains("axis") || n.contains("hikvision")) return "Kamera"
if (n.contains("printer") || n.contains("drucker")) return "Drucker"
if (n.contains("fritz") || n.contains("router") || n.contains("gateway")) return "Router"
if (n.contains("switch")) return "Switch"
if (n.contains("wallbox") || n.contains("keba") || n.contains("charger")) return "Wallbox"
if (n.contains("nas") || n.contains("synology") || n.contains("diskstation")) return "NAS"
// 3. Portmuster
return when {
554 in ports -> "Kamera"
9100 in ports || 515 in ports -> "Drucker"
3389 in ports || (445 in ports && 1883 !in ports) -> "Windows-PC"
1883 in ports || 502 in ports -> "IoT/SPS"
22 in ports && (80 in ports || 443 in ports) -> "Server"
22 in ports -> "Linux-Gerät"
else -> ""
}
}
/* --------------------------------------------------------------------- */
/* mDNS / Bonjour — Drucker, Kameras, Chromecast, AirPlay … */
/* --------------------------------------------------------------------- */
/**
* mDNS-Dienstsuche über NsdManager. Liefert pro IP den Bonjour-Namen und
* die angebotenen Diensttypen. Kein Root, keine Berechtigung nötig — nur
* ein Multicast-Lock fürs WLAN. Ergebnis ist Best-Effort (Timeout-begrenzt).
*/
@PluginMethod
fun mdnsScan(call: PluginCall) {
val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong()
io.launch {
try {
val found = discoverMdns(timeoutMs)
val arr = JSArray()
for ((ip, info) in found) {
val services = JSArray()
info.services.forEach { services.put(it) }
arr.put(JSObject()
.put("ip", ip)
.put("name", info.name)
.put("services", services))
}
resolve(call, JSObject().put("devices", arr))
} catch (e: Exception) {
call.reject("mdnsScan: ${e.message}")
}
}
}
private class MdnsInfo {
var name: String = ""
val services: MutableSet<String> = ConcurrentHashMap.newKeySet()
}
@Suppress("DEPRECATION")
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
val nsd = context.applicationContext
.getSystemService(Context.NSD_SERVICE) as NsdManager
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
val types = listOf(
"_printer._tcp.", "_ipp._tcp.", "_pdl-datastream._tcp.", "_googlecast._tcp.",
"_airplay._tcp.", "_raop._tcp.", "_http._tcp.", "_workstation._tcp.",
"_smb._tcp.", "_rtsp._tcp.", "_axis-video._tcp.", "_hap._tcp.", "_ssh._tcp.",
)
val result = ConcurrentHashMap<String, MdnsInfo>()
val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
val listeners = ArrayList<NsdManager.DiscoveryListener>()
val mlock = wifi.createMulticastLock("netdiag-mdns").apply {
setReferenceCounted(true)
try { acquire() } catch (_: Exception) { }
}
try {
for (type in types) {
val l = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(s: String?, e: Int) {}
override fun onStopDiscoveryFailed(s: String?, e: Int) {}
override fun onDiscoveryStarted(s: String?) {}
override fun onDiscoveryStopped(s: String?) {}
override fun onServiceFound(info: NsdServiceInfo) { pending.add(info) }
override fun onServiceLost(info: NsdServiceInfo) {}
}
try {
nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l)
listeners.add(l)
} catch (_: Exception) { }
}
// Gefundene Dienste seriell auflösen — NsdManager.resolveService
// verträgt keine parallelen Aufrufe.
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val info = pending.poll()
if (info == null) {
Thread.sleep(100)
continue
}
val lock = CountDownLatch(1)
try {
nsd.resolveService(info, object : NsdManager.ResolveListener {
override fun onResolveFailed(s: NsdServiceInfo?, e: Int) { lock.countDown() }
override fun onServiceResolved(s: NsdServiceInfo) {
val host = s.host?.hostAddress
if (host != null) {
val mi = result.getOrPut(host) { MdnsInfo() }
if (mi.name.isEmpty()) mi.name = s.serviceName ?: ""
val t = (s.serviceType ?: "").trim('.', ' ')
if (t.isNotEmpty()) mi.services.add(t)
}
lock.countDown()
}
})
lock.await(1500, TimeUnit.MILLISECONDS)
} catch (_: Exception) { }
}
} finally {
for (l in listeners) {
try { nsd.stopServiceDiscovery(l) } catch (_: Exception) { }
}
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
}
return result
}
/**
* Alle Host-IPs (als Int) eines CIDR-Subnetzes.
* "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw.
* Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind
* ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16.
*/
private fun hostsInSubnet(cidr: String): List<Int> {
val parts = cidr.trim().split('/')
val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList()
val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24
if (prefix < 0 || prefix > 32) return emptyList()
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = ipInt and mask
val broadcast = network or mask.inv()
val out = ArrayList<Int>()
if (prefix >= 31) {
var i = network
while (true) { out.add(i); if (i == broadcast) break; i++ }
return out
}
val count = (broadcast.toLong() and 0xFFFFFFFFL) - (network.toLong() and 0xFFFFFFFFL) - 1L
if (count < 1L || count > 65534L) return emptyList()
var i = network + 1
val last = broadcast - 1
while (true) { out.add(i); if (i == last) break; i++ }
return out
}
/** "192.168.1.50" -> 32-Bit-Int (big-endian), null bei ungueltig */
private fun ipv4ToInt(s: String): Int? {
val o = s.split('.')
if (o.size != 4) return null
var v = 0
for (part in o) {
val n = part.toIntOrNull() ?: return null
if (n < 0 || n > 255) return null
v = (v shl 8) or n
}
return v
}
/** 32-Bit-Int (big-endian) -> "192.168.1.50" */
private fun intToIpv4(i: Int): String =
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
/* --------------------------------------------------------------------- */
/* Port-Scan */
/* --------------------------------------------------------------------- */
@PluginMethod
fun portScan(call: PluginCall) {
val ip = call.getString("ip") ?: return call.reject("ip fehlt")
val portsArg = call.getArray("ports") ?: JSArray()
val ports = (0 until portsArg.length()).map { portsArg.getInt(it) }
io.launch {
try {
val open = withContext(Dispatchers.IO) {
ports.map { port ->
async {
try {
Socket().use { s ->
s.connect(InetSocketAddress(ip, port), 700)
}
port
} catch (_: Exception) {
null
}
}
}.awaitAll().filterNotNull()
}
val arr = JSArray()
for (p in open) arr.put(JSObject().put("port", p).put("service", serviceName(p)))
resolve(call, JSObject().put("open", arr))
} catch (e: Exception) {
call.reject("portScan: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Ping-Qualität */
/* --------------------------------------------------------------------- */
@PluginMethod
fun pingQuality(call: PluginCall) {
val host = call.getString("host") ?: return call.reject("host fehlt")
val count = call.getInt("count") ?: 20
io.launch {
try {
resolve(call, measurePing(host, count))
} catch (e: Exception) {
call.reject("pingQuality: ${e.message}")
}
}
}
private fun measurePing(host: String, count: Int): JSObject {
val times = ArrayList<Double>()
val addr = InetAddress.getByName(host)
repeat(count) {
val t0 = System.nanoTime()
if (addr.isReachable(1000)) {
times.add((System.nanoTime() - t0) / 1_000_000.0)
}
Thread.sleep(200)
}
val received = times.size
val loss = ((count - received) * 100) / count
val min = times.minOrNull() ?: 0.0
val max = times.maxOrNull() ?: 0.0
val avg = if (times.isNotEmpty()) times.average() else 0.0
// Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte
var jitter = 0.0
for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1])
if (times.size > 1) jitter /= (times.size - 1)
return JSObject()
.put("sent", count).put("received", received).put("lossPct", loss)
.put("minMs", round1(min)).put("avgMs", round1(avg))
.put("maxMs", round1(max)).put("jitterMs", round1(jitter))
}
/* --------------------------------------------------------------------- */
/* WLAN-Scan */
/* --------------------------------------------------------------------- */
@PluginMethod
fun wifiScan(call: PluginCall) {
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
requestPermissionForAlias("location", call, "wifiScanPermCallback")
return
}
doWifiScan(call)
}
@PermissionCallback
private fun wifiScanPermCallback(call: PluginCall) {
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
doWifiScan(call)
} else {
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
}
}
private fun doWifiScan(call: PluginCall) {
try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val arr = JSArray()
for (r in wifi.scanResults) {
val freq = r.frequency
arr.put(JSObject()
.put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID)
.put("bssid", r.BSSID ?: "")
.put("channel", freqToChannel(freq))
.put("rssi", r.level)
.put("band", if (freq > 4000) "5 GHz" else "2.4 GHz"))
}
resolve(call, JSObject().put("networks", arr))
} catch (e: Exception) {
call.reject("wifiScan: ${e.message}")
}
}
/* --------------------------------------------------------------------- */
/* DHCP-Info — DHCP-Server, von dem das Gerät seine Adresse bezieht */
/* --------------------------------------------------------------------- */
/**
* Liefert den DHCP-Server, von dem das Gerät selbst seine IP bezogen hat,
* samt Lease-Dauer, Gateway und DNS.
*
* Eine aktive Rogue-DHCP-Suche (DHCPDISCOVER senden, OFFER empfangen) ist
* auf nicht gerootetem Android NICHT möglich: der Server antwortet auf
* UDP-Port 68, der ist privilegiert (< 1024) und vom System-DHCP-Client
* belegt. Darum hier nur die verlässliche, vom OS bezogene Lease-Info.
*/
@PluginMethod
fun dhcpInfo(call: PluginCall) {
io.launch {
try {
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
val out = JSObject()
val dns = JSArray()
if (dhcp != null && dhcp.serverAddress != 0) {
out.put("server", intToIp(dhcp.serverAddress))
out.put("lease", dhcp.leaseDuration)
out.put("gateway", if (dhcp.gateway != 0) intToIp(dhcp.gateway) else "")
if (dhcp.dns1 != 0) dns.put(intToIp(dhcp.dns1))
if (dhcp.dns2 != 0) dns.put(intToIp(dhcp.dns2))
} else {
// Kein WLAN-DHCP greifbar (z. B. Ethernet) — Server nicht ermittelbar
out.put("server", "")
out.put("lease", 0)
out.put("gateway", "")
}
out.put("dns", dns)
resolve(call, out)
} catch (e: Exception) {
call.reject("dhcpInfo: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* SNMP v2c GET (Switch: Link-Speed, Fehlerzähler) */
/* --------------------------------------------------------------------- */
@PluginMethod
fun snmpGet(call: PluginCall) {
val host = call.getString("host") ?: return call.reject("host fehlt")
val community = call.getString("community") ?: "public"
val oidsArg = call.getArray("oids") ?: JSArray()
val oids = (0 until oidsArg.length()).map { oidsArg.getString(it) }
io.launch {
try {
val values = JSObject()
for (oid in oids) {
values.put(oid, Snmp.get(host, community, oid) ?: "-")
}
resolve(call, JSObject().put("values", values))
} catch (e: Exception) {
call.reject("snmpGet: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Traceroute (über das System-ping-Binary, kein Root nötig) */
/* --------------------------------------------------------------------- */
@PluginMethod
fun traceroute(call: PluginCall) {
val host = call.getString("host") ?: return call.reject("host fehlt")
io.launch {
try {
val hops = JSArray()
var deadStreak = 0
for (ttl in 1..30) {
val hop = pingWithTtl(host, ttl)
hops.put(JSObject()
.put("ttl", ttl)
.put("ip", hop.ip)
.put("ms", hop.ms))
if (hop.ip == host || hop.reachedTarget) break
// Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30
deadStreak = if (hop.ip == "*") deadStreak + 1 else 0
if (deadStreak >= 5) break
}
resolve(call, JSObject().put("hops", hops))
} catch (e: Exception) {
call.reject("traceroute: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Durchsatz-Test */
/* --------------------------------------------------------------------- */
@PluginMethod
fun throughput(call: PluginCall) {
val host = call.getString("host") ?: return call.reject("host fehlt")
val port = call.getInt("port") ?: 5201
val durationSec = call.getInt("durationSec") ?: 10
io.launch {
try {
// Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle:
// Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde.
val res = measureThroughput(host, port, durationSec)
resolve(call, res)
} catch (e: Exception) {
call.reject("throughput: ${e.message}")
}
}
}
private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject {
val buf = ByteArray(64 * 1024)
var downBytes = 0L
var upBytes = 0L
// Upload-Phase
Socket().use { s ->
s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L
val out = s.getOutputStream()
while (System.currentTimeMillis() < end) {
out.write(buf); upBytes += buf.size
}
}
// Download-Phase
Socket().use { s ->
s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L
val inp = s.getInputStream()
while (System.currentTimeMillis() < end) {
val n = inp.read(buf); if (n < 0) break; downBytes += n
}
}
val secs = durationSec / 2.0
return JSObject()
.put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs))
.put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs))
}
/* --------------------------------------------------------------------- */
/* Dauer-/Stresstest */
/* --------------------------------------------------------------------- */
@PluginMethod
fun startStressTest(call: PluginCall) {
val host = call.getString("host") ?: return call.reject("host fehlt")
val durationSec = call.getInt("durationSec") ?: 300
val runId = "run-${System.currentTimeMillis()}"
val run = StressRun(host, durationSec)
stressRuns[runId] = run
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
// gestartet werden, sonst kann Android den Prozess beenden.
io.launch {
val end = System.currentTimeMillis() + durationSec * 1000L
while (System.currentTimeMillis() < end && run.active) {
val q = measurePing(host, 5)
run.samples++
run.lossSum += q.getInteger("lossPct", 0) ?: 0
run.avgSum += q.getDouble("avgMs")
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
}
}
resolve(call, JSObject().put("runId", runId))
}
@PluginMethod
fun stopStressTest(call: PluginCall) {
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
run.active = false
val n = Math.max(1, run.samples)
resolve(call, JSObject()
.put("samples", run.samples)
.put("lossPct", run.lossSum / n)
.put("avgMs", round1(run.avgSum / n))
.put("maxMs", round1(run.maxMs)))
}
private class StressRun(val host: String, val durationSec: Int) {
var active = true
var samples = 0
var lossSum = 0
var avgSum = 0.0
var maxMs = 0.0
}
/* --------------------------------------------------------------------- */
/* App-Update: APK herunterladen und Paketinstaller öffnen */
/* --------------------------------------------------------------------- */
/**
* Lädt die neue APK vom (authentifizierten) Update-Proxy des netdiag-Moduls
* herunter und öffnet den Android-Paketinstaller. Der Download-Fortschritt
* wird laufend als `updateProgress`-Event (0100 %) gemeldet.
*
* Vor Android 8 genügt die globale Einstellung „Unbekannte Quellen". Ab
* Android 8 muss die App einzeln berechtigt sein — fehlt das Recht, wird
* der passende Einstellungs-Dialog geöffnet und der Aufruf abgewiesen.
*/
@PluginMethod
fun installUpdate(call: PluginCall) {
val url = call.getString("url") ?: return call.reject("url fehlt")
io.launch {
try {
// Ab Android 8: App braucht das Recht, Pakete zu installieren
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!context.packageManager.canRequestPackageInstalls()
) {
val perm = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(perm)
call.reject("Bitte erlauben, dass NetDiag Apps installieren darf, dann erneut tippen")
return@launch
}
val apk = File(context.cacheDir, "NetDiag-update.apk")
downloadApk(url, apk)
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", apk
)
val install = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(install)
resolve(call, JSObject().put("started", true))
} catch (e: Exception) {
call.reject("installUpdate: ${e.message}")
}
}
}
/** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */
private fun downloadApk(url: String, target: File) {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
}
try {
val code = conn.responseCode
if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)")
val total = conn.contentLength.toLong() // -1 wenn unbekannt
var read = 0L
var lastPct = -1
conn.inputStream.use { input ->
FileOutputStream(target).use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
read += n
if (total > 0) {
val pct = (read * 100 / total).toInt()
if (pct != lastPct) {
lastPct = pct
notifyListeners(
"updateProgress", JSObject().put("percent", pct)
)
}
}
}
}
}
if (target.length() < 1024) throw Exception("APK unvollständig empfangen")
} finally {
conn.disconnect()
}
}
/* --------------------------------------------------------------------- */
/* Hilfsfunktionen */
/* --------------------------------------------------------------------- */
private fun resolve(call: PluginCall, data: JSObject) {
// Capacitor erwartet die Auflösung auf dem Main-Thread
activity.runOnUiThread { call.resolve(data) }
}
private fun intToIp(i: Int): String =
"${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}"
private fun firstLocalIpv4(): String {
java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni ->
ni.inetAddresses.toList().forEach { addr ->
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
return addr.hostAddress ?: ""
}
}
}
return "192.168.1.1"
}
/** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */
private fun readArpTable(): Map<String, String> {
val map = HashMap<String, String>()
try {
BufferedReader(FileReader(File("/proc/net/arp"))).use { br ->
br.readLine() // Kopfzeile
var line = br.readLine()
while (line != null) {
val parts = line.split(Regex("\\s+"))
if (parts.size >= 4 && parts[3] != "00:00:00:00:00:00") {
map[parts[0]] = parts[3].uppercase()
}
line = br.readLine()
}
}
} catch (_: Exception) { }
return map
}
/** ping mit fester TTL -> (antwortende IP, Latenz in ms) */
private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean)
private fun pingWithTtl(host: String, ttl: Int): Hop {
return try {
val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host)
.redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText()
proc.waitFor()
val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1)
?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1)
?: "*"
val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0
val reached = out.contains("bytes from")
Hop(if (reached) host else ip, ms, reached)
} catch (e: Exception) {
Hop("*", 0.0, false)
}
}
private fun freqToChannel(freq: Int): Int = when {
freq == 2484 -> 14
freq in 2412..2472 -> (freq - 2412) / 5 + 1
freq in 5170..5825 -> (freq - 5170) / 5 + 34
else -> 0
}
private fun serviceName(port: Int): String = when (port) {
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"
1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt"
else -> ""
}
/** Minimaler OUI-Hersteller-Lookup. Für vollständige Abdeckung OUI-DB einbinden. */
private fun ouiVendor(mac: String): String {
val oui = mac.replace(":", "").take(6).uppercase()
return OUI[oui] ?: ""
}
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object {
/**
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf
* Vollständigkeit — unbekannte MACs liefern einfach einen leeren Vendor.
*/
private val OUI: Map<String, String> = buildMap {
// AVM / FRITZ!Box
listOf("00040E", "3810D5", "5C4979", "C80E14", "E0286D", "3410F4")
.forEach { put(it, "AVM") }
// TP-Link
listOf("003192", "50C7BF", "D8EB97", "EC086B", "A42BB0", "1CFA68",
"14CC20", "B0487A", "6032B1", "5091E3").forEach { put(it, "TP-Link") }
// Netgear
listOf("000FB5", "20E52A", "00146C", "001B2F", "001E2A", "00223F",
"0024B2", "28C68E", "A040A0", "3C3786", "6CB0CE").forEach { put(it, "Netgear") }
// Ubiquiti
listOf("00156D", "0418D6", "24A43C", "44D9E7", "687251", "788A20",
"802AA8", "B4FB0E", "DC9FDB", "F09FC2", "FCECDA", "74ACB9",
"18E829", "944A0C", "E063DA").forEach { put(it, "Ubiquiti") }
// Cisco
listOf("00000C", "001AA1", "0023AC", "F09E63").forEach { put(it, "Cisco") }
// MikroTik
listOf("000C42", "4C5E0C", "6C3B6B", "CC2DE0", "E48D8C", "64D154",
"B869F4", "18FD74", "2CC81B", "DC2C6E", "744D28", "488F5A")
.forEach { put(it, "MikroTik") }
// D-Link
listOf("001195", "1CBDB9", "001B11", "001CF0", "14D64D", "28107B",
"78542E", "B8A386", "C8BE19").forEach { put(it, "D-Link") }
// Hikvision (Kameras)
listOf("4419B6", "BCAD28", "C05627", "2857BE", "4CBD8F", "54C415",
"A41437", "B4A382", "18680F").forEach { put(it, "Hikvision") }
// Dahua (Kameras)
listOf("3CEF8C", "9002A9", "14A78B", "E0508B", "08EDED", "24526A",
"6C1C71").forEach { put(it, "Dahua") }
// Axis (Kameras)
listOf("00408C", "ACCC8E", "B8A44F", "E82725").forEach { put(it, "Axis") }
// Drucker
listOf("001E0B", "3CD92B", "9457A5", "001321", "A0481C", "308D99",
"380025", "00215A", "9C8E99", "EC8EB5", "705A0F", "B499BA")
.forEach { put(it, "HP") }
listOf("008077", "30055C", "001BA9").forEach { put(it, "Brother") }
listOf("002673", "88873D", "F48139", "2C9EFC", "001E8F", "B08E1A")
.forEach { put(it, "Canon") }
listOf("000048", "0026AB", "A4EE57", "64EB8C", "44D244", "381A52")
.forEach { put(it, "Epson") }
// Apple
listOf("F0B479", "3C0754", "A4B197", "DC2B2A", "040CCE", "7CD1C3",
"F0DBF8", "88665A", "28CFE9", "001EC2", "002500", "D8A25E")
.forEach { put(it, "Apple") }
// Samsung
listOf("002566", "8425DB", "5CF6DC", "0017C9", "001A8A", "3423BA",
"781FDB", "8C7712", "BC1485", "5C0A5B").forEach { put(it, "Samsung") }
// Espressif (ESP32/ESP8266 — Shelly, Tasmota, viele IoT-Geräte)
listOf("240AC4", "30AEA4", "246F28", "84CCA8", "A020A6", "7C9EBD",
"8CAAB5", "3C6105", "24B2DE", "DC4F22", "84F3EB", "BCDDC2",
"A4CF12", "CC50E3", "2462AB", "18FE34", "5CCF7F", "600194",
"2C3AE8", "ECFABC", "B4E62D", "9038C9").forEach { put(it, "Espressif") }
// Raspberry Pi
listOf("B827EB", "DCA632", "E45F01", "28CDC1", "D83ADD", "2CCF67")
.forEach { put(it, "Raspberry Pi") }
// Intel
listOf("001CC0", "3CA9F4", "A0A8CD", "8C1645", "7CB27D", "9C305B",
"0013E8", "5C514F", "94659C").forEach { put(it, "Intel") }
// Sonos
listOf("000E58", "5CAAFD", "949F3E", "B8E937", "347E5C", "48A6B8",
"542A1B").forEach { put(it, "Sonos") }
// NAS
listOf("001132", "9009D0").forEach { put(it, "Synology") }
listOf("00089B", "245EBE").forEach { put(it, "QNAP") }
// Amazon (Echo / Fire)
listOf("8871E5", "FCA183", "44650D", "F0272D", "68DBF5", "50DCE7",
"AC63BE", "40B4CD", "0C47C9", "74C246").forEach { put(it, "Amazon") }
// Google / Nest / Chromecast
listOf("F4F5D8", "F4F5E8", "30FD38", "6CADF8", "546009", "A47733",
"1CF29A", "3C5AB4", "D86C63", "48D6D5").forEach { put(it, "Google") }
// Industrie / Gebäudetechnik
listOf("000E8C", "001B1B", "286336", "001C06", "8CF319")
.forEach { put(it, "Siemens") }
put("00A057", "Lancom")
put("001A22", "eQ-3 / Homematic")
}
}
}