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(null) } var wifiOptions by remember { mutableStateOf>(emptyList()) } var selectedOptionKey by rememberSaveable { mutableStateOf(null) } var customCidr by rememberSaveable { mutableStateOf("") } var customCidrError by rememberSaveable { mutableStateOf(null) } var networkSnapshot by remember { mutableStateOf(null) } val devices = remember { mutableStateListOf() } var isScanning by rememberSaveable { mutableStateOf(false) } var scanProgress by rememberSaveable { mutableStateOf(0f) } var lastScanLabel by rememberSaveable { mutableStateOf("Jamais") } var lastScanTimestampMs by rememberSaveable { mutableStateOf(null) } var onlineCount by rememberSaveable { mutableStateOf(0) } var offlineCount by rememberSaveable { mutableStateOf(0) } var totalCount by rememberSaveable { mutableStateOf(0) } var currentScanIp by rememberSaveable { mutableStateOf(null) } var scanError by rememberSaveable { mutableStateOf(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(null) } val scope = rememberCoroutineScope() var scanJob by remember { mutableStateOf(null) } var serverJob by remember { mutableStateOf(null) } var serverSocket by remember { mutableStateOf(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() 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, 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, 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 ) 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 == "") { "Wi-Fi" } else { ssid } } private fun buildScanTargets( localAddress: Inet4Address, scanPrefixLength: Int ): List { 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(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, onProgress: suspend (Float) -> Unit, onIpScanned: suspend (String) -> Unit ): List { 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() 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, onProgress: suspend (Float) -> Unit, onIpScanned: suspend (String) -> Unit ): List { if (targets.isEmpty()) return emptyList() val found = mutableListOf() 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): Map { val arpFile = File("/proc/net/arp") if (!arpFile.exists()) return emptyMap() val entries = mutableMapOf() 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 } } }