Files
TestScanLAN/app/src/main/java/fr/openium/testscanlan/MainActivity.kt
2026-04-01 19:19:05 +02:00

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
}
}
}