All checks were successful
Build APK / build-apk (push) Successful in 1m41s
- IP-Scanner: ConnectivityManager.getLinkProperties statt hartcodiertem /24 – erkennt jetzt das echte Subnetz inkl. Prefix-Länge und Gateway - DHCP: dhcpDiscover durch dhcpInfo ersetzt (liest WifiManager.dhcpInfo, kein Root nötig) – zeigt Server, Gateway, Lease-Zeit, DNS - Aufträge: tms-Feld ergänzt, Order by tms DESC – "zuletzt bearbeitet" zuerst; Checkbox-Logik invertiert (Standard: alle Aufträge, Haken = nur aktive) - MeasurementResult-Komponente: Arrays (WLAN-Netze, Traceroute-Hops) als echte Liste statt Komma-String; Skalare kompakt in einer Zeile - Traceroute: 5 aufeinanderfolgende Timeouts → Abbruch statt endlos warten - tools/types.ts: MeasureStatus exportiert (behebt 5 Svelte-Check-Fehler) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
701 lines
29 KiB
Kotlin
701 lines
29 KiB
Kotlin
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.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.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
|
||
|
||
/**
|
||
* 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()
|
||
val devices = JSArray()
|
||
for (ip in alive) {
|
||
val dev = JSObject().put("ip", ip)
|
||
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
||
try {
|
||
val name = InetAddress.getByName(ip).canonicalHostName
|
||
if (name != ip) dev.put("hostname", name)
|
||
} catch (_: Exception) { }
|
||
devices.put(dev)
|
||
}
|
||
resolve(call, JSObject().put("devices", devices))
|
||
} catch (e: Exception) {
|
||
call.reject("ipScan: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 (0–100 %) 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 {
|
||
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
|
||
private val OUI = mapOf(
|
||
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
||
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
||
)
|
||
}
|
||
}
|