1500 lines
59 KiB
Kotlin
1500 lines
59 KiB
Kotlin
package fr.openium.testscanlan
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Context
|
|
import android.net.ConnectivityManager
|
|
import android.net.Network
|
|
import android.net.NetworkCapabilities
|
|
import android.net.wifi.WifiManager
|
|
import android.os.Bundle
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.Divider
|
|
import androidx.compose.material3.FilledTonalButton
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.RadioButton
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.input.KeyboardType
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.compose.ui.unit.dp
|
|
import fr.openium.testscanlan.ui.theme.Ink
|
|
import fr.openium.testscanlan.ui.theme.Pine
|
|
import fr.openium.testscanlan.ui.theme.Rust
|
|
import fr.openium.testscanlan.ui.theme.TestScanLANTheme
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.ensureActive
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.sync.Semaphore
|
|
import kotlinx.coroutines.sync.withPermit
|
|
import kotlinx.coroutines.withContext
|
|
import java.io.File
|
|
import java.io.InputStream
|
|
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.ServerSocket
|
|
import java.net.Socket
|
|
import java.net.SocketException
|
|
import java.net.URL
|
|
import java.util.concurrent.atomic.AtomicInteger
|
|
|
|
class MainActivity : ComponentActivity() {
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
enableEdgeToEdge()
|
|
setContent {
|
|
TestScanLANTheme {
|
|
LanScanScreen()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
data class DeviceInfo(
|
|
val name: String,
|
|
val ip: String,
|
|
val mac: String,
|
|
val vendor: String,
|
|
val latencyMs: Int?,
|
|
val status: PingStatus
|
|
)
|
|
|
|
enum class PingStatus {
|
|
Reachable,
|
|
Unreachable,
|
|
Unknown
|
|
}
|
|
|
|
private const val CUSTOM_OPTION_KEY = "custom"
|
|
|
|
@Composable
|
|
fun LanScanScreen() {
|
|
val context = LocalContext.current
|
|
val connectivityManager = remember {
|
|
context.getSystemService(ConnectivityManager::class.java)
|
|
}
|
|
var wifiInfo by remember { mutableStateOf<WifiNetworkInfo?>(null) }
|
|
var wifiOptions by remember { mutableStateOf<List<WifiAddressOption>>(emptyList()) }
|
|
var selectedOptionKey by rememberSaveable { mutableStateOf<String?>(null) }
|
|
var customCidr by rememberSaveable { mutableStateOf("") }
|
|
var customCidrError by rememberSaveable { mutableStateOf<String?>(null) }
|
|
var networkSnapshot by remember { mutableStateOf<NetworkSnapshot?>(null) }
|
|
val devices = remember { mutableStateListOf<DeviceInfo>() }
|
|
var isScanning by rememberSaveable { mutableStateOf(false) }
|
|
var scanProgress by rememberSaveable { mutableStateOf(0f) }
|
|
var lastScanLabel by rememberSaveable { mutableStateOf("Jamais") }
|
|
var lastScanTimestampMs by rememberSaveable { mutableStateOf<Long?>(null) }
|
|
var onlineCount by rememberSaveable { mutableStateOf(0) }
|
|
var offlineCount by rememberSaveable { mutableStateOf(0) }
|
|
var totalCount by rememberSaveable { mutableStateOf(0) }
|
|
var currentScanIp by rememberSaveable { mutableStateOf<String?>(null) }
|
|
var scanError by rememberSaveable { mutableStateOf<String?>(null) }
|
|
var requestPath by rememberSaveable { mutableStateOf("/") }
|
|
var requestPort by rememberSaveable { mutableStateOf("80") }
|
|
var dialogVisible by rememberSaveable { mutableStateOf(false) }
|
|
var dialogTitle by rememberSaveable { mutableStateOf("") }
|
|
var dialogBody by rememberSaveable { mutableStateOf("") }
|
|
var dialogLoading by rememberSaveable { mutableStateOf(false) }
|
|
var serverRunning by rememberSaveable { mutableStateOf(false) }
|
|
var serverError by rememberSaveable { mutableStateOf<String?>(null) }
|
|
val scope = rememberCoroutineScope()
|
|
var scanJob by remember { mutableStateOf<Job?>(null) }
|
|
var serverJob by remember { mutableStateOf<Job?>(null) }
|
|
var serverSocket by remember { mutableStateOf<ServerSocket?>(null) }
|
|
val serverPort = 8080
|
|
|
|
fun applySelectionSnapshot(info: WifiNetworkInfo?) {
|
|
val options = info?.addresses.orEmpty()
|
|
wifiOptions = options
|
|
if (selectedOptionKey == null || (selectedOptionKey != CUSTOM_OPTION_KEY &&
|
|
options.none { it.key == selectedOptionKey })
|
|
) {
|
|
selectedOptionKey = options.firstOrNull()?.key
|
|
}
|
|
if (info == null) {
|
|
networkSnapshot = null
|
|
return
|
|
}
|
|
if (selectedOptionKey == CUSTOM_OPTION_KEY) {
|
|
val cidr = parseCidr(customCidr)
|
|
if (cidr == null) {
|
|
customCidrError = "Format attendu: 192.168.1.0/24"
|
|
networkSnapshot = null
|
|
return
|
|
}
|
|
customCidrError = null
|
|
val localOption = options.firstOrNull()
|
|
if (localOption == null) {
|
|
networkSnapshot = null
|
|
return
|
|
}
|
|
networkSnapshot = buildWifiSnapshot(
|
|
info = info,
|
|
localAddress = localOption.address,
|
|
localPrefixLength = localOption.prefixLength,
|
|
scanBaseAddress = cidr.base,
|
|
scanPrefixLength = cidr.prefix
|
|
)
|
|
} else {
|
|
customCidrError = null
|
|
val option = options.firstOrNull { it.key == selectedOptionKey } ?: options.firstOrNull()
|
|
if (option == null) {
|
|
networkSnapshot = null
|
|
return
|
|
}
|
|
networkSnapshot = buildWifiSnapshot(
|
|
info = info,
|
|
localAddress = option.address,
|
|
localPrefixLength = option.prefixLength,
|
|
scanBaseAddress = option.address,
|
|
scanPrefixLength = option.prefixLength
|
|
)
|
|
}
|
|
}
|
|
|
|
fun refreshWifiInfo() {
|
|
val info = resolveWifiNetworkInfo(context, connectivityManager)
|
|
wifiInfo = info
|
|
applySelectionSnapshot(info)
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
refreshWifiInfo()
|
|
scanError = null
|
|
}
|
|
|
|
fun startScan() {
|
|
scanJob?.cancel()
|
|
scanJob = scope.launch {
|
|
refreshWifiInfo()
|
|
val snapshot = networkSnapshot
|
|
if (snapshot == null) {
|
|
devices.clear()
|
|
onlineCount = 0
|
|
offlineCount = 0
|
|
totalCount = 0
|
|
scanProgress = 0f
|
|
isScanning = false
|
|
lastScanLabel = "Indisponible"
|
|
scanError = "Aucun reseau Wi-Fi actif ou pas d'IPv4."
|
|
return@launch
|
|
}
|
|
scanError = null
|
|
isScanning = true
|
|
scanProgress = 0f
|
|
lastScanLabel = "En cours"
|
|
currentScanIp = null
|
|
devices.clear()
|
|
onlineCount = 0
|
|
offlineCount = 0
|
|
val targets = buildScanTargets(snapshot.scanBaseAddress, snapshot.scanPrefixLength)
|
|
.filter { it.hostAddress != snapshot.localAddress.hostAddress }
|
|
totalCount = targets.size
|
|
try {
|
|
val wifiResult = runCatching {
|
|
scanWifiNetwork(snapshot, targets, onProgress = { progress ->
|
|
scanProgress = progress
|
|
}, onIpScanned = { ip ->
|
|
currentScanIp = ip
|
|
})
|
|
}
|
|
val found = if (wifiResult.isSuccess) {
|
|
wifiResult.getOrThrow()
|
|
} else {
|
|
scanError = "Scan Wi-Fi degrade (ARP non accessible)."
|
|
scanTcpNetwork(snapshot, targets, onProgress = { progress ->
|
|
scanProgress = progress
|
|
}, onIpScanned = { ip ->
|
|
currentScanIp = ip
|
|
})
|
|
}
|
|
val filteredFound = found.filter { it.ip != snapshot.localAddress.hostAddress }
|
|
devices.clear()
|
|
devices.addAll(filteredFound.sortedBy { it.ip })
|
|
onlineCount = filteredFound.size
|
|
offlineCount = (targets.size - filteredFound.size).coerceAtLeast(0)
|
|
scanProgress = 1f
|
|
val now = System.currentTimeMillis()
|
|
lastScanTimestampMs = now
|
|
lastScanLabel = formatElapsed(now, now)
|
|
} catch (cancelled: CancellationException) {
|
|
scanProgress = 0f
|
|
lastScanLabel = "Annule"
|
|
} catch (_: Exception) {
|
|
scanError = "Erreur pendant le scan."
|
|
lastScanLabel = "Echec"
|
|
} finally {
|
|
isScanning = false
|
|
currentScanIp = null
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(Unit) {
|
|
startScan()
|
|
}
|
|
|
|
LaunchedEffect(lastScanTimestampMs) {
|
|
while (true) {
|
|
val timestamp = lastScanTimestampMs ?: break
|
|
if (!isScanning) {
|
|
lastScanLabel = formatElapsed(System.currentTimeMillis(), timestamp)
|
|
}
|
|
delay(30_000)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(networkSnapshot) {
|
|
while (true) {
|
|
delay(10_000)
|
|
if (isScanning) continue
|
|
val snapshot = networkSnapshot ?: continue
|
|
val currentDevices = devices.toList()
|
|
if (currentDevices.isEmpty()) continue
|
|
val results = withContext(Dispatchers.IO) {
|
|
val map = mutableMapOf<String, Int?>()
|
|
currentDevices.forEach { device ->
|
|
val latency = runCatching {
|
|
val address = InetAddress.getByName(device.ip)
|
|
probeTcp(snapshot.network, address)
|
|
}.getOrNull()
|
|
map[device.ip] = latency
|
|
}
|
|
map
|
|
}
|
|
results.forEach { (ip, latency) ->
|
|
val index = devices.indexOfFirst { it.ip == ip }
|
|
if (index != -1) {
|
|
val old = devices[index]
|
|
val newStatus =
|
|
if (latency != null) PingStatus.Reachable else PingStatus.Unreachable
|
|
devices[index] = old.copy(latencyMs = latency, status = newStatus)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun stopServer() {
|
|
serverJob?.cancel()
|
|
serverJob = null
|
|
try {
|
|
serverSocket?.close()
|
|
} catch (_: Exception) {
|
|
}
|
|
serverSocket = null
|
|
serverRunning = false
|
|
}
|
|
|
|
fun startServer() {
|
|
if (serverRunning) return
|
|
serverError = null
|
|
try {
|
|
val socket = ServerSocket()
|
|
socket.reuseAddress = true
|
|
socket.bind(InetSocketAddress(serverPort))
|
|
serverSocket = socket
|
|
serverRunning = true
|
|
serverJob = scope.launch(Dispatchers.IO) {
|
|
while (isActive) {
|
|
try {
|
|
val client = socket.accept()
|
|
launch(Dispatchers.IO) {
|
|
handleHttpClient(client)
|
|
}
|
|
} catch (socketClosed: SocketException) {
|
|
break
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
}
|
|
} catch (ex: Exception) {
|
|
serverError = ex.message ?: "Erreur serveur"
|
|
stopServer()
|
|
}
|
|
}
|
|
|
|
DisposableEffect(Unit) {
|
|
onDispose {
|
|
stopServer()
|
|
}
|
|
}
|
|
|
|
fun testDevice(ip: String) {
|
|
val path = normalizePath(requestPath)
|
|
val port = requestPort.trim()
|
|
val portNumber = port.toIntOrNull()
|
|
if (portNumber == null || portNumber !in 1..65535) {
|
|
dialogTitle = "Port invalide"
|
|
dialogBody = "Saisis un port entre 1 et 65535."
|
|
dialogLoading = false
|
|
dialogVisible = true
|
|
return
|
|
}
|
|
val url = "http://$ip:$portNumber$path"
|
|
dialogTitle = url
|
|
dialogBody = ""
|
|
dialogLoading = true
|
|
dialogVisible = true
|
|
scope.launch {
|
|
val result = withContext(Dispatchers.IO) {
|
|
runCatching { fetchHttpPreview(url) }.getOrElse { ex ->
|
|
"Erreur: ${ex.message ?: "inconnue"}"
|
|
}
|
|
}
|
|
dialogBody = result
|
|
dialogLoading = false
|
|
}
|
|
}
|
|
|
|
LanScanScreenContent(
|
|
devices = devices,
|
|
isScanning = isScanning,
|
|
scanProgress = scanProgress,
|
|
lastScanLabel = lastScanLabel,
|
|
onScanClick = { startScan() },
|
|
networkSnapshot = networkSnapshot,
|
|
scanError = scanError,
|
|
onlineCount = onlineCount,
|
|
offlineCount = offlineCount,
|
|
totalCount = totalCount,
|
|
currentScanIp = currentScanIp,
|
|
requestPath = requestPath,
|
|
onRequestPathChange = { requestPath = it },
|
|
requestPort = requestPort,
|
|
onRequestPortChange = { requestPort = it },
|
|
wifiOptions = wifiOptions,
|
|
selectedOptionKey = selectedOptionKey,
|
|
onOptionSelected = { key ->
|
|
selectedOptionKey = key
|
|
applySelectionSnapshot(wifiInfo)
|
|
},
|
|
customCidr = customCidr,
|
|
onCustomCidrChange = { value ->
|
|
customCidr = value
|
|
if (selectedOptionKey == CUSTOM_OPTION_KEY) {
|
|
applySelectionSnapshot(wifiInfo)
|
|
}
|
|
},
|
|
customCidrError = customCidrError,
|
|
onTestDevice = { device -> testDevice(device.ip) },
|
|
dialogVisible = dialogVisible,
|
|
dialogTitle = dialogTitle,
|
|
dialogBody = dialogBody,
|
|
dialogLoading = dialogLoading,
|
|
onDialogDismiss = { dialogVisible = false },
|
|
serverRunning = serverRunning,
|
|
serverError = serverError,
|
|
serverPort = serverPort,
|
|
onStartServer = { startServer() },
|
|
onStopServer = { stopServer() }
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
private fun LanScanScreenContent(
|
|
devices: List<DeviceInfo>,
|
|
isScanning: Boolean,
|
|
scanProgress: Float,
|
|
lastScanLabel: String,
|
|
onScanClick: () -> Unit,
|
|
networkSnapshot: NetworkSnapshot?,
|
|
scanError: String?,
|
|
onlineCount: Int,
|
|
offlineCount: Int,
|
|
totalCount: Int,
|
|
currentScanIp: String?,
|
|
requestPath: String,
|
|
onRequestPathChange: (String) -> Unit,
|
|
requestPort: String,
|
|
onRequestPortChange: (String) -> Unit,
|
|
wifiOptions: List<WifiAddressOption>,
|
|
selectedOptionKey: String?,
|
|
onOptionSelected: (String) -> Unit,
|
|
customCidr: String,
|
|
onCustomCidrChange: (String) -> Unit,
|
|
customCidrError: String?,
|
|
onTestDevice: (DeviceInfo) -> Unit,
|
|
dialogVisible: Boolean,
|
|
dialogTitle: String,
|
|
dialogBody: String,
|
|
dialogLoading: Boolean,
|
|
onDialogDismiss: () -> Unit,
|
|
serverRunning: Boolean,
|
|
serverError: String?,
|
|
serverPort: Int,
|
|
onStartServer: () -> Unit,
|
|
onStopServer: () -> Unit
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(
|
|
MaterialTheme.colorScheme.background
|
|
)
|
|
) {
|
|
Scaffold(
|
|
containerColor = Color.Transparent,
|
|
contentColor = MaterialTheme.colorScheme.onBackground
|
|
) { innerPadding ->
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxSize(),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
contentPadding = PaddingValues(
|
|
top = 16.dp + innerPadding.calculateTopPadding(),
|
|
bottom = 24.dp + innerPadding.calculateBottomPadding()
|
|
)
|
|
) {
|
|
item {
|
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
|
Text(
|
|
text = "Scan reseau local",
|
|
style = MaterialTheme.typography.displaySmall,
|
|
color = MaterialTheme.colorScheme.onBackground
|
|
)
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Text(
|
|
text = "Liste des appareils detectes sur le Wi-Fi et leur reponse au ping.",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f)
|
|
)
|
|
Spacer(modifier = Modifier.height(18.dp))
|
|
if (scanError != null) {
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(
|
|
text = scanError,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.height(18.dp))
|
|
NetworkCard(
|
|
title = "Reseau Wi-Fi",
|
|
ssid = networkSnapshot?.label ?: "Wi-Fi",
|
|
ipRange = networkSnapshot?.ipRange ?: "--",
|
|
gateway = networkSnapshot?.gateway ?: "--",
|
|
localIp = networkSnapshot?.localAddress?.hostAddress ?: "--"
|
|
)
|
|
if (wifiOptions.isNotEmpty()) {
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Text(
|
|
text = "Plage IP a scanner",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
wifiOptions.forEach { option ->
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
RadioButton(
|
|
selected = option.key == selectedOptionKey,
|
|
onClick = { onOptionSelected(option.key) }
|
|
)
|
|
Text(
|
|
text = option.label,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
}
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
RadioButton(
|
|
selected = selectedOptionKey == CUSTOM_OPTION_KEY,
|
|
onClick = { onOptionSelected(CUSTOM_OPTION_KEY) }
|
|
)
|
|
Text(
|
|
text = "Personnalisee",
|
|
style = MaterialTheme.typography.bodyMedium
|
|
)
|
|
}
|
|
if (selectedOptionKey == CUSTOM_OPTION_KEY) {
|
|
OutlinedTextField(
|
|
value = customCidr,
|
|
onValueChange = onCustomCidrChange,
|
|
label = { Text(text = "Plage personnalisée (CIDR)") },
|
|
placeholder = { Text(text = "192.168.1.0/24") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
if (customCidrError != null) {
|
|
Text(
|
|
text = customCidrError,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Card(
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
|
shape = RoundedCornerShape(18.dp),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Text(
|
|
text = "Mini serveur",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
val ipLabel =
|
|
networkSnapshot?.localAddress?.hostAddress ?: "--"
|
|
Text(
|
|
text = "http://$ipLabel:$serverPort/",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
Text(
|
|
text = "Repond: true",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
if (serverError != null) {
|
|
Text(
|
|
text = serverError,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
if (serverRunning) {
|
|
Button(onClick = onStopServer) {
|
|
Text(text = "Arreter")
|
|
}
|
|
} else {
|
|
Button(onClick = onStartServer) {
|
|
Text(text = "Demarrer")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Column {
|
|
Text(
|
|
text = "Dernier scan",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = lastScanLabel,
|
|
style = MaterialTheme.typography.titleLarge
|
|
)
|
|
}
|
|
Button(
|
|
onClick = onScanClick,
|
|
enabled = !isScanning && networkSnapshot != null,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.primary
|
|
)
|
|
) {
|
|
Text(text = if (isScanning) "Scan..." else "Scanner")
|
|
}
|
|
}
|
|
if (isScanning) {
|
|
Spacer(modifier = Modifier.height(10.dp))
|
|
LinearProgressIndicator(
|
|
progress = scanProgress,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = MaterialTheme.colorScheme.secondary,
|
|
trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
)
|
|
currentScanIp?.let { ip ->
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Text(
|
|
text = "Scan en cours: $ip",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f),
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
OutlinedTextField(
|
|
value = requestPath,
|
|
onValueChange = onRequestPathChange,
|
|
label = { Text(text = "Chemin URL a tester") },
|
|
placeholder = { Text(text = "/ping") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = requestPort,
|
|
onValueChange = { value ->
|
|
onRequestPortChange(value.filter { it.isDigit() }.take(5))
|
|
},
|
|
label = { Text(text = "Port") },
|
|
placeholder = { Text(text = "80") },
|
|
singleLine = true,
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
}
|
|
stickyHeader {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.background(MaterialTheme.colorScheme.background)
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 20.dp, vertical = 12.dp)
|
|
) {
|
|
Text(
|
|
text = "Appareils trouves",
|
|
style = MaterialTheme.typography.titleLarge
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(999.dp),
|
|
tonalElevation = 2.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(
|
|
horizontal = 12.dp,
|
|
vertical = 6.dp
|
|
),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = "$onlineCount en ligne",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = Pine
|
|
)
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Text(
|
|
text = "$offlineCount hors ligne",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = Rust
|
|
)
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Text(
|
|
text = "$totalCount total",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (devices.isEmpty() && !isScanning) {
|
|
item {
|
|
Box(modifier = Modifier.padding(horizontal = 20.dp)) {
|
|
EmptyState()
|
|
}
|
|
}
|
|
} else {
|
|
itemsIndexed(
|
|
items = devices,
|
|
key = { _, device -> device.ip }
|
|
) { index, device ->
|
|
AnimatedVisibility(
|
|
visible = true,
|
|
enter = fadeIn(animationSpec = tween(240, delayMillis = index * 40)) +
|
|
slideInVertically(
|
|
initialOffsetY = { it / 2 },
|
|
animationSpec = tween(240, delayMillis = index * 40)
|
|
)
|
|
) {
|
|
Box(modifier = Modifier.padding(horizontal = 20.dp)) {
|
|
DeviceCard(
|
|
device = device,
|
|
onTestDevice = { onTestDevice(device) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dialogVisible) {
|
|
AlertDialog(
|
|
onDismissRequest = onDialogDismiss,
|
|
title = { Text(text = dialogTitle) },
|
|
text = {
|
|
Column(
|
|
modifier = Modifier
|
|
.heightIn(max = 360.dp)
|
|
.verticalScroll(rememberScrollState())
|
|
) {
|
|
if (dialogLoading) {
|
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(text = "Requete en cours...")
|
|
} else {
|
|
Text(text = dialogBody)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = onDialogDismiss) {
|
|
Text(text = "Fermer")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun NetworkCard(
|
|
title: String,
|
|
ssid: String,
|
|
ipRange: String,
|
|
gateway: String,
|
|
localIp: String
|
|
) {
|
|
Card(
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
|
shape = RoundedCornerShape(20.dp)
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Text(
|
|
text = ssid,
|
|
style = MaterialTheme.typography.titleLarge,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Divider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Row(
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column {
|
|
Text(
|
|
text = "Plage IP (scan)",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = ipRange,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
Column(horizontalAlignment = Alignment.End) {
|
|
Text(
|
|
text = "Gateway",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = gateway,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Text(
|
|
text = "IP de cet appareil",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = localIp,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DeviceCard(
|
|
device: DeviceInfo,
|
|
onTestDevice: () -> Unit
|
|
) {
|
|
Card(
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
|
shape = RoundedCornerShape(18.dp),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
text = device.name,
|
|
style = MaterialTheme.typography.titleLarge,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = device.vendor,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
StatusPill(status = device.status)
|
|
}
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Column {
|
|
Text(
|
|
text = "IP",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = device.ip,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
}
|
|
Column(horizontalAlignment = Alignment.End) {
|
|
Text(
|
|
text = "Ping",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
val pingLabel = device.latencyMs?.let { "$it ms" } ?: "--"
|
|
Text(
|
|
text = pingLabel,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = if (device.status == PingStatus.Reachable) Pine else Rust
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(10.dp))
|
|
Text(
|
|
text = "MAC ${device.mac}",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
|
fontFamily = FontFamily.Monospace
|
|
)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Button(onClick = onTestDevice) {
|
|
Text(text = "Tester URL")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun StatusPill(status: PingStatus) {
|
|
val (label, color) = when (status) {
|
|
PingStatus.Reachable -> "Ping OK" to Pine
|
|
PingStatus.Unreachable -> "Sans reponse" to Rust
|
|
PingStatus.Unknown -> "Inconnu" to Ink
|
|
}
|
|
Surface(
|
|
color = color.copy(alpha = 0.14f),
|
|
shape = RoundedCornerShape(999.dp)
|
|
) {
|
|
Text(
|
|
text = label,
|
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = color
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun EmptyState() {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surface,
|
|
shape = RoundedCornerShape(18.dp),
|
|
tonalElevation = 2.dp,
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(20.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(56.dp)
|
|
.background(
|
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
|
shape = RoundedCornerShape(16.dp)
|
|
)
|
|
)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
Text(
|
|
text = "Aucun appareil detecte",
|
|
style = MaterialTheme.typography.titleLarge
|
|
)
|
|
Spacer(modifier = Modifier.height(6.dp))
|
|
Text(
|
|
text = "Lancez un scan pour voir les appareils qui repondent au ping.",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
)
|
|
Spacer(modifier = Modifier.height(14.dp))
|
|
FilledTonalButton(onClick = {}) {
|
|
Text(text = "Voir les details")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Preview(showBackground = true)
|
|
@Composable
|
|
fun LanScanPreview() {
|
|
TestScanLANTheme {
|
|
LanScanScreenContent(
|
|
devices = emptyList(),
|
|
isScanning = false,
|
|
scanProgress = 0f,
|
|
lastScanLabel = "Jamais",
|
|
onScanClick = {},
|
|
networkSnapshot = null,
|
|
scanError = null,
|
|
onlineCount = 0,
|
|
offlineCount = 0,
|
|
totalCount = 0,
|
|
currentScanIp = null,
|
|
requestPath = "/",
|
|
onRequestPathChange = {},
|
|
requestPort = "80",
|
|
onRequestPortChange = {},
|
|
wifiOptions = emptyList(),
|
|
selectedOptionKey = null,
|
|
onOptionSelected = {},
|
|
customCidr = "",
|
|
onCustomCidrChange = {},
|
|
customCidrError = null,
|
|
onTestDevice = {},
|
|
dialogVisible = false,
|
|
dialogTitle = "",
|
|
dialogBody = "",
|
|
dialogLoading = false,
|
|
onDialogDismiss = {},
|
|
serverRunning = false,
|
|
serverError = null,
|
|
serverPort = 8080,
|
|
onStartServer = {},
|
|
onStopServer = {}
|
|
)
|
|
}
|
|
}
|
|
|
|
private data class NetworkSnapshot(
|
|
val network: Network,
|
|
val localAddress: Inet4Address,
|
|
val localPrefixLength: Int,
|
|
val scanBaseAddress: Inet4Address,
|
|
val scanPrefixLength: Int,
|
|
val label: String,
|
|
val ipRange: String,
|
|
val gateway: String
|
|
)
|
|
|
|
private data class WifiAddressOption(
|
|
val address: Inet4Address,
|
|
val prefixLength: Int
|
|
) {
|
|
val key: String = "${address.hostAddress}/$prefixLength"
|
|
val label: String = key
|
|
}
|
|
|
|
private data class WifiNetworkInfo(
|
|
val network: Network,
|
|
val label: String,
|
|
val gateway: String,
|
|
val addresses: List<WifiAddressOption>
|
|
)
|
|
|
|
private fun resolveWifiNetworkInfo(
|
|
context: Context,
|
|
connectivityManager: ConnectivityManager
|
|
): WifiNetworkInfo? {
|
|
val targetNetwork = connectivityManager.allNetworks.firstOrNull { network ->
|
|
val capabilities =
|
|
connectivityManager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
} ?: return null
|
|
val linkProperties = connectivityManager.getLinkProperties(targetNetwork) ?: return null
|
|
val addresses = linkProperties.linkAddresses.mapNotNull { linkAddress ->
|
|
val address = linkAddress.address as? Inet4Address ?: return@mapNotNull null
|
|
WifiAddressOption(address, linkAddress.prefixLength.coerceIn(0, 32))
|
|
}
|
|
if (addresses.isEmpty()) return null
|
|
val gateway = linkProperties.routes.firstOrNull { route ->
|
|
route.isDefaultRoute && route.gateway is Inet4Address
|
|
}?.gateway?.hostAddress ?: "--"
|
|
val label = resolveWifiLabel(context)
|
|
return WifiNetworkInfo(
|
|
network = targetNetwork,
|
|
label = label,
|
|
gateway = gateway,
|
|
addresses = addresses
|
|
)
|
|
}
|
|
|
|
private fun buildWifiSnapshot(
|
|
info: WifiNetworkInfo,
|
|
localAddress: Inet4Address,
|
|
localPrefixLength: Int,
|
|
scanBaseAddress: Inet4Address,
|
|
scanPrefixLength: Int
|
|
): NetworkSnapshot {
|
|
val ipRange = formatRange(scanBaseAddress, scanPrefixLength)
|
|
return NetworkSnapshot(
|
|
network = info.network,
|
|
localAddress = localAddress,
|
|
localPrefixLength = localPrefixLength,
|
|
scanBaseAddress = scanBaseAddress,
|
|
scanPrefixLength = scanPrefixLength,
|
|
label = info.label,
|
|
ipRange = ipRange,
|
|
gateway = info.gateway
|
|
)
|
|
}
|
|
|
|
@SuppressLint("MissingPermission")
|
|
private fun resolveWifiLabel(context: Context): String {
|
|
val wifiManager = context.applicationContext.getSystemService(WifiManager::class.java)
|
|
val ssid = wifiManager?.connectionInfo?.ssid?.removeSurrounding("\"")
|
|
return if (ssid.isNullOrBlank() || ssid == WifiManager.UNKNOWN_SSID || ssid == "<unknown ssid>") {
|
|
"Wi-Fi"
|
|
} else {
|
|
ssid
|
|
}
|
|
}
|
|
|
|
private fun buildScanTargets(
|
|
localAddress: Inet4Address,
|
|
scanPrefixLength: Int
|
|
): List<Inet4Address> {
|
|
val prefix = scanPrefixLength.coerceIn(0, 32)
|
|
val ipValue = inet4ToInt(localAddress).toLong() and 0xFFFFFFFFL
|
|
val mask = if (prefix == 0) 0L else (-1L shl (32 - prefix)) and 0xFFFFFFFFL
|
|
val network = ipValue and mask
|
|
val hostCount = 1L shl (32 - prefix)
|
|
val start = if (hostCount <= 2L) network else network + 1
|
|
val end = if (hostCount <= 2L) network + hostCount - 1 else network + hostCount - 2
|
|
val targets = ArrayList<Inet4Address>(hostCount.coerceAtMost(256L).toInt())
|
|
var current = start
|
|
while (current <= end) {
|
|
targets.add(intToInet4(current.toInt()))
|
|
current += 1
|
|
}
|
|
return targets
|
|
}
|
|
|
|
private suspend fun scanWifiNetwork(
|
|
snapshot: NetworkSnapshot,
|
|
targets: List<Inet4Address>,
|
|
onProgress: suspend (Float) -> Unit,
|
|
onIpScanned: suspend (String) -> Unit
|
|
): List<DeviceInfo> {
|
|
if (targets.isEmpty()) return emptyList()
|
|
return withContext(Dispatchers.IO) {
|
|
val addressSet = targets.map { it.hostAddress }.toSet()
|
|
val socket = DatagramSocket()
|
|
try {
|
|
snapshot.network.bindSocket(socket)
|
|
val payload = byteArrayOf(0)
|
|
val total = targets.size
|
|
targets.forEachIndexed { index, address ->
|
|
ensureActive()
|
|
try {
|
|
val packet = DatagramPacket(payload, payload.size, address, 9)
|
|
socket.send(packet)
|
|
} catch (_: Exception) {
|
|
}
|
|
if (index == total - 1 || index % 4 == 0) {
|
|
withContext(Dispatchers.Main) { onIpScanned(address.hostAddress) }
|
|
}
|
|
if (index == total - 1 || index % 16 == 0) {
|
|
val progress = (index + 1).toFloat() / total
|
|
withContext(Dispatchers.Main) { onProgress(progress * 0.6f) }
|
|
}
|
|
}
|
|
} finally {
|
|
socket.close()
|
|
}
|
|
delay(350)
|
|
val arpEntries = readArpTable(addressSet)
|
|
val devices = mutableMapOf<String, DeviceInfo>()
|
|
arpEntries.forEach { (ip, mac) ->
|
|
devices[ip] = buildDevice(ip, mac, snapshot.localAddress, null)
|
|
}
|
|
val localIp = snapshot.localAddress.hostAddress
|
|
if (localIp in addressSet && !devices.containsKey(localIp)) {
|
|
devices[localIp] = buildDevice(localIp, null, snapshot.localAddress, null)
|
|
}
|
|
val remainingTargets = targets.filter { it.hostAddress !in devices.keys }
|
|
if (remainingTargets.isNotEmpty()) {
|
|
val completed = AtomicInteger(0)
|
|
val totalRemaining = remainingTargets.size
|
|
val semaphore = Semaphore(16)
|
|
val lock = Any()
|
|
kotlinx.coroutines.coroutineScope {
|
|
remainingTargets.forEach { address ->
|
|
launch(Dispatchers.IO) {
|
|
semaphore.withPermit {
|
|
ensureActive()
|
|
withContext(Dispatchers.Main) { onIpScanned(address.hostAddress) }
|
|
val latency = probeTcp(snapshot.network, address)
|
|
if (latency != null) {
|
|
val device = buildDevice(
|
|
address.hostAddress,
|
|
null,
|
|
snapshot.localAddress,
|
|
latency
|
|
)
|
|
synchronized(lock) {
|
|
devices[address.hostAddress] = device
|
|
}
|
|
}
|
|
}
|
|
val done = completed.incrementAndGet()
|
|
if (done == totalRemaining || done % 8 == 0) {
|
|
val progress = 0.6f + (done.toFloat() / totalRemaining) * 0.4f
|
|
withContext(Dispatchers.Main) { onProgress(progress) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
withContext(Dispatchers.Main) { onProgress(1f) }
|
|
}
|
|
devices.values.toList()
|
|
}
|
|
}
|
|
|
|
private suspend fun scanTcpNetwork(
|
|
snapshot: NetworkSnapshot,
|
|
targets: List<Inet4Address>,
|
|
onProgress: suspend (Float) -> Unit,
|
|
onIpScanned: suspend (String) -> Unit
|
|
): List<DeviceInfo> {
|
|
if (targets.isEmpty()) return emptyList()
|
|
val found = mutableListOf<DeviceInfo>()
|
|
val lock = Any()
|
|
val completed = AtomicInteger(0)
|
|
val total = targets.size
|
|
val semaphore = Semaphore(24)
|
|
return kotlinx.coroutines.coroutineScope {
|
|
targets.forEach { address ->
|
|
launch(Dispatchers.IO) {
|
|
semaphore.withPermit {
|
|
ensureActive()
|
|
withContext(Dispatchers.Main) { onIpScanned(address.hostAddress) }
|
|
val latency = probeTcp(snapshot.network, address)
|
|
if (latency != null) {
|
|
synchronized(lock) {
|
|
found.add(
|
|
buildDevice(
|
|
address.hostAddress,
|
|
null,
|
|
snapshot.localAddress,
|
|
latency
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
val done = completed.incrementAndGet()
|
|
if (done == total || done % 8 == 0) {
|
|
withContext(Dispatchers.Main) { onProgress(done.toFloat() / total) }
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
}
|
|
|
|
private fun probeTcp(network: Network, address: InetAddress): Int? {
|
|
val ports = intArrayOf(443, 80, 8080, 22, 53, 445)
|
|
val startNs = System.nanoTime()
|
|
for (port in ports) {
|
|
try {
|
|
val socket = network.socketFactory.createSocket() as Socket
|
|
socket.use {
|
|
it.soTimeout = 250
|
|
it.connect(InetSocketAddress(address, port), 250)
|
|
}
|
|
val elapsedMs = (System.nanoTime() - startNs) / 1_000_000
|
|
return elapsedMs.toInt()
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private fun readArpTable(allowedIps: Set<String>): Map<String, String> {
|
|
val arpFile = File("/proc/net/arp")
|
|
if (!arpFile.exists()) return emptyMap()
|
|
val entries = mutableMapOf<String, String>()
|
|
try {
|
|
arpFile.readLines()
|
|
.drop(1)
|
|
.forEach { line ->
|
|
val parts = line.trim().split(Regex("\\s+"))
|
|
if (parts.size >= 4) {
|
|
val ip = parts[0]
|
|
val flags = parts[2]
|
|
val mac = parts[3]
|
|
if ((allowedIps.isEmpty() || ip in allowedIps) && flags == "0x2" && mac != "00:00:00:00:00:00") {
|
|
entries[ip] = mac
|
|
}
|
|
}
|
|
}
|
|
} catch (_: Exception) {
|
|
return emptyMap()
|
|
}
|
|
return entries
|
|
}
|
|
|
|
private fun buildDevice(
|
|
ip: String,
|
|
mac: String?,
|
|
localAddress: Inet4Address,
|
|
latencyMs: Int?
|
|
): DeviceInfo {
|
|
val label = if (ip == localAddress.hostAddress) {
|
|
"Ce telephone"
|
|
} else {
|
|
"Appareil ${ip.substringAfterLast('.')}"
|
|
}
|
|
return DeviceInfo(
|
|
name = label,
|
|
ip = ip,
|
|
mac = mac ?: "--",
|
|
vendor = "Inconnu",
|
|
latencyMs = latencyMs,
|
|
status = PingStatus.Reachable
|
|
)
|
|
}
|
|
|
|
private fun formatRange(address: Inet4Address, prefixLength: Int): String {
|
|
val prefix = prefixLength.coerceIn(0, 32)
|
|
val ipValue = inet4ToInt(address)
|
|
val mask = if (prefix == 0) 0 else -1 shl (32 - prefix)
|
|
val network = ipValue and mask
|
|
return "${intToInet4(network).hostAddress}/$prefix"
|
|
}
|
|
|
|
private fun inet4ToInt(address: Inet4Address): Int {
|
|
val bytes = address.address
|
|
return (bytes[0].toInt() and 0xFF shl 24) or
|
|
(bytes[1].toInt() and 0xFF shl 16) or
|
|
(bytes[2].toInt() and 0xFF shl 8) or
|
|
(bytes[3].toInt() and 0xFF)
|
|
}
|
|
|
|
private fun intToInet4(value: Int): Inet4Address {
|
|
val bytes = byteArrayOf(
|
|
((value shr 24) and 0xFF).toByte(),
|
|
((value shr 16) and 0xFF).toByte(),
|
|
((value shr 8) and 0xFF).toByte(),
|
|
(value and 0xFF).toByte()
|
|
)
|
|
return InetAddress.getByAddress(bytes) as Inet4Address
|
|
}
|
|
|
|
private fun normalizePath(rawPath: String): String {
|
|
val trimmed = rawPath.trim()
|
|
if (trimmed.isEmpty()) return "/"
|
|
return if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
|
}
|
|
|
|
private data class Cidr(val base: Inet4Address, val prefix: Int)
|
|
|
|
private fun parseCidr(input: String): Cidr? {
|
|
val trimmed = input.trim()
|
|
if (trimmed.isEmpty()) return null
|
|
val parts = trimmed.split("/")
|
|
val addressPart = parts[0]
|
|
val prefix = if (parts.size > 1) parts[1].toIntOrNull() ?: return null else 24
|
|
if (prefix !in 0..32) return null
|
|
val address = runCatching { InetAddress.getByName(addressPart) }.getOrNull() as? Inet4Address
|
|
?: return null
|
|
val mask = if (prefix == 0) 0 else -1 shl (32 - prefix)
|
|
val network = inet4ToInt(address) and mask
|
|
val base = intToInet4(network)
|
|
return Cidr(base = base, prefix = prefix)
|
|
}
|
|
|
|
private fun formatElapsed(nowMs: Long, timestampMs: Long): String {
|
|
val diffSeconds = ((nowMs - timestampMs).coerceAtLeast(0L)) / 1000
|
|
return when {
|
|
diffSeconds < 60 -> "A l'instant"
|
|
diffSeconds < 3600 -> {
|
|
val minutes = (diffSeconds / 60).toInt()
|
|
"Il y a ${minutes} min"
|
|
}
|
|
|
|
diffSeconds < 86_400 -> {
|
|
val hours = (diffSeconds / 3600).toInt()
|
|
"Il y a ${hours} h"
|
|
}
|
|
|
|
else -> {
|
|
val days = (diffSeconds / 86_400).toInt()
|
|
"Il y a ${days} j"
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun fetchHttpPreview(url: String): String {
|
|
val connection = URL(url).openConnection() as HttpURLConnection
|
|
connection.connectTimeout = 1500
|
|
connection.readTimeout = 1500
|
|
connection.requestMethod = "GET"
|
|
connection.instanceFollowRedirects = true
|
|
return try {
|
|
val code = connection.responseCode
|
|
val contentType = connection.contentType ?: "unknown"
|
|
val bodyStream: InputStream? = if (code in 200..299) {
|
|
connection.inputStream
|
|
} else {
|
|
connection.errorStream
|
|
}
|
|
val body = bodyStream?.bufferedReader()?.use { it.readText() } ?: ""
|
|
val preview = body.take(2000)
|
|
"HTTP $code\n$contentType\n\n$preview"
|
|
} finally {
|
|
connection.disconnect()
|
|
}
|
|
}
|
|
|
|
private fun handleHttpClient(socket: Socket) {
|
|
socket.use { client ->
|
|
client.soTimeout = 2000
|
|
try {
|
|
val input = client.getInputStream().bufferedReader()
|
|
val requestLine = input.readLine() ?: return
|
|
val parts = requestLine.split(" ")
|
|
val path = if (parts.size >= 2) parts[1] else "/"
|
|
while (true) {
|
|
val line = input.readLine() ?: break
|
|
if (line.isEmpty()) break
|
|
}
|
|
val body = if (path == "/") "true" else "false"
|
|
val status = if (path == "/") "200 OK" else "404 Not Found"
|
|
val bytes = body.toByteArray()
|
|
val response = buildString {
|
|
append("HTTP/1.1 ").append(status).append("\r\n")
|
|
append("Content-Type: text/plain; charset=utf-8\r\n")
|
|
append("Content-Length: ").append(bytes.size).append("\r\n")
|
|
append("Connection: close\r\n\r\n")
|
|
}.toByteArray()
|
|
client.getOutputStream().apply {
|
|
write(response)
|
|
write(bytes)
|
|
flush()
|
|
}
|
|
} catch (_: java.net.SocketTimeoutException) {
|
|
return
|
|
} catch (_: Exception) {
|
|
return
|
|
}
|
|
}
|
|
}
|