commit 05fcb5ae888fc401d087cf506bba1c2c47ff12d5 Author: Ludovic Godart Date: Wed Apr 1 19:19:05 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1a1bf72 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/swift-toolchain.xml b/.idea/swift-toolchain.xml new file mode 100644 index 0000000..99df217 --- /dev/null +++ b/.idea/swift-toolchain.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..135f6a0 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# TestScanLAN + +Mini application Android (Jetpack Compose) pour scanner un reseau local Wi-Fi, detecter les appareils repondant au ping, et tester des URLs sur chaque IP detectee. Inclut un mini serveur HTTP local pour valider la detection entre deux appareils. + +## Captures + +![Capture 1](captures/Screenshot_20260401_191420.png) + +![Capture 2](captures/Screenshot_20260401_191449.png) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fb136fe --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "fr.openium.testscanlan" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "fr.openium.testscanlan" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/fr/openium/testscanlan/ExampleInstrumentedTest.kt b/app/src/androidTest/java/fr/openium/testscanlan/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..10d6029 --- /dev/null +++ b/app/src/androidTest/java/fr/openium/testscanlan/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package fr.openium.testscanlan + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("fr.openium.testscanlan", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b64236d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/fr/openium/testscanlan/MainActivity.kt b/app/src/main/java/fr/openium/testscanlan/MainActivity.kt new file mode 100644 index 0000000..e3c3dd2 --- /dev/null +++ b/app/src/main/java/fr/openium/testscanlan/MainActivity.kt @@ -0,0 +1,1499 @@ +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 + } + } +} diff --git a/app/src/main/java/fr/openium/testscanlan/ui/theme/Color.kt b/app/src/main/java/fr/openium/testscanlan/ui/theme/Color.kt new file mode 100644 index 0000000..5025175 --- /dev/null +++ b/app/src/main/java/fr/openium/testscanlan/ui/theme/Color.kt @@ -0,0 +1,17 @@ +package fr.openium.testscanlan.ui.theme + +import androidx.compose.ui.graphics.Color + +val Seafoam = Color(0xFF1B8A7A) +val SeafoamDark = Color(0xFF0E4B44) +val Sun = Color(0xFFF4A24B) +val Sky = Color(0xFF5C7CFA) + +val Ink = Color(0xFF0B1F2A) +val DeepInk = Color(0xFF09161F) +val Mist = Color(0xFFF4F6F3) +val Cloud = Color(0xFFE2E8E4) + +val Pine = Color(0xFF1E6B3A) +val Rust = Color(0xFFB0462F) +val Slate = Color(0xFF2B3B44) diff --git a/app/src/main/java/fr/openium/testscanlan/ui/theme/Theme.kt b/app/src/main/java/fr/openium/testscanlan/ui/theme/Theme.kt new file mode 100644 index 0000000..d230f8a --- /dev/null +++ b/app/src/main/java/fr/openium/testscanlan/ui/theme/Theme.kt @@ -0,0 +1,62 @@ +package fr.openium.testscanlan.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Seafoam, + secondary = Sun, + tertiary = Sky, + background = DeepInk, + surface = Ink, + onPrimary = Mist, + onSecondary = DeepInk, + onTertiary = Mist, + onBackground = Mist, + onSurface = Mist +) + +private val LightColorScheme = lightColorScheme( + primary = Seafoam, + secondary = Sun, + tertiary = Sky, + background = Mist, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Ink, + onTertiary = Color.White, + onBackground = Ink, + onSurface = Ink +) + +@Composable +fun TestScanLANTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/fr/openium/testscanlan/ui/theme/Type.kt b/app/src/main/java/fr/openium/testscanlan/ui/theme/Type.kt new file mode 100644 index 0000000..5bc23cd --- /dev/null +++ b/app/src/main/java/fr/openium/testscanlan/ui/theme/Type.kt @@ -0,0 +1,54 @@ +package fr.openium.testscanlan.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + displaySmall = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.25).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..494d367 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + TestScanLAN + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..80a98fa --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +