All checks were successful
Build APK / build-apk (push) Successful in 1m42s
Ersetzt den Browser-Umweg (window.open) durch einen echten In-App-Installer: das native Plugin lädt die APK streamend herunter (Fortschritts-Events updateProgress 0–100 %), prüft die Installationsberechtigung (Android 8+) und öffnet den Paketinstaller über den vorhandenen FileProvider. Versionsvergleich jetzt numerisch (YYYYMMDD-HHMM) statt lexikografisch. Banner ist schließbar; Einstellungsseite zeigt separaten Fortschrittsbalken. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
646 lines
26 KiB
Kotlin
646 lines
26 KiB
Kotlin
package de.data_it_solution.netdiag
|
||
|
||
import android.Manifest
|
||
import android.content.Context
|
||
import android.content.Intent
|
||
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.DatagramPacket
|
||
import java.net.DatagramSocket
|
||
import java.net.HttpURLConnection
|
||
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 {
|
||
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
||
val ipInt = dhcp?.ipAddress ?: 0
|
||
val gwInt = dhcp?.gateway ?: 0
|
||
val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4()
|
||
val gateway = if (gwInt != 0) intToIp(gwInt) else ""
|
||
val base = ip.substringBeforeLast('.', "192.168.1")
|
||
resolve(call, JSObject()
|
||
.put("subnet", "$base.0/24")
|
||
.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 base = subnet.substringBeforeLast('.', "192.168.1")
|
||
io.launch {
|
||
try {
|
||
// Parallel-Ping über das gesamte /24
|
||
val alive = withContext(Dispatchers.IO) {
|
||
(1..254).map { host ->
|
||
async {
|
||
val ip = "$base.$host"
|
||
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}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/* --------------------------------------------------------------------- */
|
||
/* 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-Discover (Rogue-DHCP-Erkennung) */
|
||
/* --------------------------------------------------------------------- */
|
||
|
||
@PluginMethod
|
||
fun dhcpDiscover(call: PluginCall) {
|
||
io.launch {
|
||
try {
|
||
val servers = discoverDhcpServers()
|
||
val arr = JSArray()
|
||
val arp = readArpTable()
|
||
for (ip in servers) {
|
||
arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: ""))
|
||
}
|
||
resolve(call, JSObject().put("servers", arr))
|
||
} catch (e: Exception) {
|
||
call.reject("dhcpDiscover: ${e.message}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server.
|
||
* Best-effort: scheitert auf manchen Geräten an Port-68-Belegung.
|
||
*/
|
||
private fun discoverDhcpServers(): List<String> {
|
||
val found = LinkedHashSet<String>()
|
||
val socket = DatagramSocket()
|
||
socket.broadcast = true
|
||
socket.soTimeout = 3000
|
||
try {
|
||
val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12)
|
||
val packet = buildDhcpDiscover(xid)
|
||
socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67))
|
||
val buf = ByteArray(1500)
|
||
val deadline = System.currentTimeMillis() + 3000
|
||
while (System.currentTimeMillis() < deadline) {
|
||
try {
|
||
val resp = DatagramPacket(buf, buf.size)
|
||
socket.receive(resp)
|
||
// Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender
|
||
val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress
|
||
if (srv != null) found.add(srv)
|
||
} catch (_: Exception) {
|
||
break
|
||
}
|
||
}
|
||
} finally {
|
||
socket.close()
|
||
}
|
||
return found.toList()
|
||
}
|
||
|
||
/* --------------------------------------------------------------------- */
|
||
/* 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()
|
||
for (ttl in 1..20) {
|
||
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
|
||
}
|
||
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
|
||
|
||
private fun buildDhcpDiscover(xid: ByteArray): ByteArray {
|
||
val p = ByteArray(300)
|
||
p[0] = 1 // op = BOOTREQUEST
|
||
p[1] = 1 // htype = Ethernet
|
||
p[2] = 6 // hlen
|
||
System.arraycopy(xid, 0, p, 4, 4)
|
||
p[10] = 0x80.toByte() // Broadcast-Flag
|
||
// Magic Cookie
|
||
p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99
|
||
// Option 53: DHCP Message Type = DISCOVER
|
||
p[240] = 53; p[241] = 1; p[242] = 1
|
||
p[243] = 255.toByte() // Ende
|
||
return p
|
||
}
|
||
|
||
private fun parseDhcpServerId(buf: ByteArray, len: Int): String? {
|
||
var i = 240
|
||
while (i + 1 < len) {
|
||
val opt = buf[i].toInt() and 0xFF
|
||
if (opt == 255) break
|
||
if (opt == 0) { i++; continue }
|
||
val l = buf[i + 1].toInt() and 0xFF
|
||
if (opt == 54 && l == 4) {
|
||
return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." +
|
||
"${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}"
|
||
}
|
||
i += 2 + l
|
||
}
|
||
return null
|
||
}
|
||
|
||
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"
|
||
)
|
||
}
|
||
}
|