diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..b55e478 --- /dev/null +++ b/android/README.md @@ -0,0 +1,2 @@ +export IOT_USER_TOKEN=demo-user-alice +export IOT_BACKEND_URL=http://127.0.0.1:5000 diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..a1e6156 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") version "2.4.20" + application +} + +repositories { + mavenCentral() +} + +application { + mainClass.set("iotmonitor.MainKt") +} + +kotlin { + jvmToolchain(17) +} \ No newline at end of file diff --git a/android/iot-monitor.jar b/android/iot-monitor.jar new file mode 100644 index 0000000..0795827 Binary files /dev/null and b/android/iot-monitor.jar differ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..9b024d0 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "iot-monitor-android" \ No newline at end of file diff --git a/android/src/main/kotlin/iotmonitor/BackendClient.kt b/android/src/main/kotlin/iotmonitor/BackendClient.kt new file mode 100644 index 0000000..4bf9ccd --- /dev/null +++ b/android/src/main/kotlin/iotmonitor/BackendClient.kt @@ -0,0 +1,80 @@ +package iotmonitor + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * Logique réseau minimale. Réutilisable telle quelle dans une MainActivity. + * + * Règles de sécurité (modules 03/04) : + * - le secret de device passe par en-tête, jamais loggé ; + * - on n'imprime que l'identité et le résultat ; + * - les erreurs sont remontées sans détail d'implémentation. + */ +class BackendClient( + private val baseUrl: String, + private val http: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(), +) { + + /** Envoie une mesure en tant que device. Retourne le code de statut HTTP. */ + fun ingest(deviceId: String, deviceSecret: String, measure: Measure): Int { + val body = buildIngestBody(measure) + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/ingest")) + .header("Content-Type", "application/json") + .header("X-Device-ID", deviceId) + .header("X-Device-Secret", deviceSecret) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + // Le secret n'est jamais imprimé. + println("[ingest] device=$deviceId -> HTTP ${response.statusCode()}") + return response.statusCode() + } + + /** + * Liste les mesures d'un device en tant qu'utilisateur. + * L'autorisation est décidée par le backend (module 03) : 403 => refus. + */ + fun listMeasures(userToken: String, deviceId: String): List { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/measures?device_id=$deviceId")) + .header("Authorization", "Bearer $userToken") + .GET() + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + println("[measures] device=$deviceId -> HTTP ${response.statusCode()}") + + if (response.statusCode() != 200) { + return emptyList() + } + return parseMeasures(response.body()) + } + + private fun buildIngestBody(measure: Measure): String = + """{"device_id":"${measure.device_id}","timestamp":"${measure.timestamp}", "light_value":${measure.light_value}}""" + + private fun parseMeasures(body: String): List { + // Parse très simple, sans dépendance JSON : on extrait chaque objet + // entre accolades (ordre des champs indifférent), puis les valeurs + // par clé. Suffisant pour le format minimal du backend. + val objectRegex = Regex("""\{[^{}]*\}""") + val fieldRegex = Regex(""""([a-z_]+)":"?([^",\}]+)"?""") + + return objectRegex.findAll(body).mapNotNull { objMatch -> + val fields = fieldRegex.findAll(objMatch.value) + .associate { m -> m.groupValues[1] to m.groupValues[2] } + val deviceId = fields["device_id"] ?: return@mapNotNull null + val timestamp = fields["timestamp"] ?: return@mapNotNull null + val light = fields["light_value"]?.toLongOrNull() ?: return@mapNotNull null + Measure(device_id = deviceId, timestamp = timestamp, light_value = light) + }.toList() + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/iotmonitor/Main.kt b/android/src/main/kotlin/iotmonitor/Main.kt new file mode 100644 index 0000000..8b8873e --- /dev/null +++ b/android/src/main/kotlin/iotmonitor/Main.kt @@ -0,0 +1,76 @@ +package iotmonitor + +import java.time.Instant + +/** + * CLI minimale : `ingest` ou `measures`. + * + * Aucun secret en dur : tout vient de l'environnement (ou renvoie une + * erreur lisible). L'état « connecté » est dérivé du code de statut HTTP. + */ +fun main(args: Array) { + val command = args.firstOrNull() + ?: run { + println("usage: ingest | measures --device ") + return + } + + val baseUrl = System.getenv("IOT_BACKEND_URL") + ?: run { + println("erreur : IOT_BACKEND_URL manquant (ex. http://127.0.0.1:5000)") + return + } + + val client = BackendClient(baseUrl) + + when (command) { + "ingest" -> runIngest(client) + "measures" -> runMeasures(client, args) + else -> println("commande inconnue : $command") + } +} + +private fun runIngest(client: BackendClient) { + val deviceId = System.getenv("IOT_DEVICE_ID") + ?: run { + println("erreur : IOT_DEVICE_ID manquant") + return + } + val secret = System.getenv("IOT_DEVICE_SECRET") + ?: run { + println("erreur : IOT_DEVICE_SECRET manquant (ne jamais mettre en dur)") + return + } + + val measure = Measure( + device_id = deviceId, + timestamp = Instant.now().toString(), + light_value = (0..1023).random().toLong(), // valeur brute simulée + ) + + val status = client.ingest(deviceId, secret, measure) + println("état : ${if (status in 200..299) "connecté (accepté)" else "refusé (HTTP $status)"}") +} + +private fun runMeasures(client: BackendClient, args: Array) { + val deviceId = args.getOrNull(args.indexOf("--device") + 1) + ?: run { + println("usage: measures --device ") + return + } + val token = System.getenv("IOT_USER_TOKEN") + ?: run { + println("erreur : IOT_USER_TOKEN manquant") + return + } + + val measures = client.listMeasures(token, deviceId) + if (measures.isEmpty()) { + println("état : aucune mesure retournée ou accès refusé") + } else { + println("état : connecté — ${measures.size} mesure(s)") + measures.forEach { m -> + println(" device=${m.device_id} time=${m.timestamp} value=${m.light_value}") + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/iotmonitor/Measure.kt b/android/src/main/kotlin/iotmonitor/Measure.kt new file mode 100644 index 0000000..5a50cc5 --- /dev/null +++ b/android/src/main/kotlin/iotmonitor/Measure.kt @@ -0,0 +1,11 @@ +package iotmonitor + +/** + * Modèle de données minimal (seulement les champs exposés par l'API, + * pas de sur-exposition — module 04). + */ +data class Measure( + val device_id: String, + val timestamp: String, + val light_value: Long, +) \ No newline at end of file