From 119dfed26bcf2b77e24ea4fd268b5539b4886109 Mon Sep 17 00:00:00 2001 From: nbschultz97 <126931519+nbschultz97@users.noreply.github.com> Date: Mon, 1 Dec 2025 03:20:10 -0700 Subject: [PATCH] Implement Wi-Fi scanning pipeline and Android build scaffolding --- .gitignore | 19 +++- README.md | 29 +++-- app/build.gradle | 68 ++++++++++++ app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 34 ++++++ .../dronedetect/DroneSignalDetector.kt | 82 ++++++++++++-- .../com/example/dronedetect/MainActivity.kt | 104 +++++++++++++++++- app/src/main/res/layout/activity_main.xml | 52 +++++++++ app/src/main/res/values-night/themes.xml | 6 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 6 + build.gradle | 21 ++++ gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 18 +++ gradlew.bat | 17 +++ settings.gradle | 2 + 16 files changed, 440 insertions(+), 27 deletions(-) create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore index 6799dc9..57fb4d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,16 @@ -# Generated binary assets -__pycache__/ -gradle/wrapper/gradle-wrapper.jar +# Gradle and build outputs +.gradle/ +build/ +app/build/ + +# Local Android Studio config +.idea/ +*.iml +local.properties + +# Generated assets from bootstrap +app/src/main/assets/ +app/src/main/res/drawable/*.png + +# OS cruft +.DS_Store diff --git a/README.md b/README.md index 469ccad..be49136 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,39 @@ # DroneDetectAndroid -This repo hosts Android-side utilities for drone rotor detection. -Binary assets (TensorFlow Lite model and icons) are stored in base64 to -keep the repo lean for air-gapped deployments. +Android-side utilities for rotor detection and Wi‑Fi reconnaissance. Assets ship as base64 to keep the repo lean for air-gapped deployments and are restored automatically during Gradle builds. ## Bootstrap -After cloning, restore the binary assets: +After cloning, restore the binary assets (TensorFlow Lite model and PNG drawables): ```bash python3 scripts/bootstrap_assets.py ``` -This will recreate the `rotor_v1.tflite` model and the required PNG -drawables under `app/src/main/...`. +Gradle also depends on this script via the `preBuild` hook, so IDE and CI builds will automatically decode assets into `app/src/main/...` if Python is available. ## Pipeline -1. **Asset bootstrap** – Decode the model and icons from `assets/*.b64` using the script above. Generated files land under `app/src/main/...`. -2. **Runtime wiring** – `MainActivity` creates a `DroneSignalDetector`, which launches a coroutine to fetch flight data and schedule Wi‑Fi scans. -3. **Scan handling** – `WifiScanReceiver` receives scan results. Future work will feed the TFLite model for rotor classification. +1. **Asset bootstrap** – Decode the model and icons from `assets/*.b64` using the script above (or let Gradle call it). +2. **Runtime wiring** – `MainActivity` requests Wi‑Fi/location permissions, wires a simple UI, and calls `DroneSignalDetector.startScan()`. +3. **Scan handling** – `DroneSignalDetector` schedules repeated `WifiManager.startScan()` calls, ingests stub flight data, and surfaces scan results plus the last telemetry snapshot to the UI via callbacks. + +## Building + +This repo ships a minimal Gradle wrapper script for offline/air-gapped work. Supply a compatible `gradle/wrapper/gradle-wrapper.jar` (from Gradle 8.2.1) in your environment, then run: + +```bash +./gradlew assembleDebug +``` + +If you prefer a hosted toolchain, open the project in Android Studio (Giraffe+), accept SDK prompts, and build the `app` module. Remember to run the asset bootstrap script if Python is missing from your build host. ## Testing -Verify the asset bootstrap step restores the binaries: +To validate the asset bootstrap step: ```bash python3 scripts/bootstrap_assets.py ``` -Each asset should report a `decoded` message. Remove the generated `app/src/main/assets` and `app/src/main/res` directories after testing to keep the repo text‑only. +Each asset should report a `decoded` message. Remove the generated `app/src/main/assets` and `app/src/main/res/drawable` contents after testing to keep the repo text‑only. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..5673364 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,68 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" +} + +android { + namespace "com.example.dronedetect" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.dronedetect" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + viewBinding = true + } + + tasks.named("preBuild") { + dependsOn("bootstrapAssets") + } +} + +val bootstrapAssets by tasks.registering { task -> + task.group = "assets" + task.description = "Decode bundled base64 assets into their binary forms" + task.doLast { + exec { + commandLine("python3", rootProject.file("scripts/bootstrap_assets.py").absolutePath) + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fbe9532 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Keep file intentionally minimal; add rules when obfuscation is enabled. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..715d628 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/dronedetect/DroneSignalDetector.kt b/app/src/main/java/com/example/dronedetect/DroneSignalDetector.kt index b1d2180..8a2c85f 100644 --- a/app/src/main/java/com/example/dronedetect/DroneSignalDetector.kt +++ b/app/src/main/java/com/example/dronedetect/DroneSignalDetector.kt @@ -3,42 +3,100 @@ package com.example.dronedetect import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.ScanResult import android.net.wifi.WifiManager import android.os.Handler import android.os.Looper +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.time.Instant -class DroneSignalDetector(private val context: Context) { +class DroneSignalDetector( + private val context: Context, + private val onResults: (List, FlightSnapshot) -> Unit = { _, _ -> } +) { private val handler = Handler(Looper.getMainLooper()) private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - private val receiver = WifiScanReceiver() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val receiver = WifiScanReceiver(wifiManager, ::handleScanResults) + private var scanScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var isScanning = false + private var latestFlightData: FlightSnapshot = FlightSnapshot(timestamp = Instant.now(), source = "bootstrap", payload = emptyMap()) fun startScan() { - scope.launch { - fetchFlightData() - // TODO: implement scanning logic - } + if (isScanning) return + isScanning = true + context.registerReceiver(receiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)) + scanScope.launch { fetchFlightData() } + requestScan() } suspend fun fetchFlightData() = withContext(Dispatchers.IO) { - // TODO: implement fetching flight data + // Placeholder for real RF/telemetry ingestion. + // Here we stamp a deterministic payload so downstream processing can + // align Wi-Fi RSSI snapshots with the last known telemetry sample. + latestFlightData = FlightSnapshot( + timestamp = Instant.now(), + source = "local_stub", + payload = mapOf( + "note" to "Replace with CSI/telemetry feed", + "status" to "idle" + ) + ) + Log.d(TAG, "Flight data refreshed at ${latestFlightData.timestamp}") } fun stopScan() { + if (!isScanning) return + isScanning = false handler.removeCallbacksAndMessages(null) - context.unregisterReceiver(receiver) - scope.cancel() + runCatching { context.unregisterReceiver(receiver) } + scanScope.cancel() + scanScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + private fun requestScan() { + val started = wifiManager.startScan() + if (!started) { + Log.w(TAG, "Wi-Fi scan request rejected by platform") + } + handler.postDelayed({ + if (isScanning) { + requestScan() + } + }, SCAN_INTERVAL_MS) + } + + private fun handleScanResults(results: List) { + if (!isScanning) return + onResults(results, latestFlightData) + } + + companion object { + private const val TAG = "DroneSignalDetector" + private const val SCAN_INTERVAL_MS = 10_000L } } -class WifiScanReceiver : BroadcastReceiver() { +data class FlightSnapshot( + val timestamp: Instant, + val source: String, + val payload: Map +) + +private class WifiScanReceiver( + private val wifiManager: WifiManager, + private val onResults: (List) -> Unit +) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - // TODO: handle scan results + val action = intent?.action ?: return + if (action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return + val results = wifiManager.scanResults.orEmpty() + onResults(results) } } diff --git a/app/src/main/java/com/example/dronedetect/MainActivity.kt b/app/src/main/java/com/example/dronedetect/MainActivity.kt index 3ba7504..f316b83 100644 --- a/app/src/main/java/com/example/dronedetect/MainActivity.kt +++ b/app/src/main/java/com/example/dronedetect/MainActivity.kt @@ -1,18 +1,120 @@ package com.example.dronedetect +import android.Manifest +import android.content.pm.PackageManager +import android.net.wifi.ScanResult +import android.os.Build import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.view.isVisible +import java.time.format.DateTimeFormatter class MainActivity : AppCompatActivity() { private lateinit var detector: DroneSignalDetector + private lateinit var statusView: TextView + private lateinit var scanSummaryView: TextView + private lateinit var startButton: Button + private lateinit var stopButton: Button + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grantResults -> + val granted = grantResults.values.any { it } + statusView.text = if (granted) { + "Permissions granted. Starting scan…" + } else { + "Location/Wi‑Fi permissions are required." + } + if (granted) { + startScanning() + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - detector = DroneSignalDetector(this) + setContentView(R.layout.activity_main) + + statusView = findViewById(R.id.statusView) + scanSummaryView = findViewById(R.id.scanSummary) + startButton = findViewById(R.id.startButton) + stopButton = findViewById(R.id.stopButton) + + detector = DroneSignalDetector(this, ::renderScanResults) + + startButton.setOnClickListener { ensurePermissionsAndStart() } + stopButton.setOnClickListener { + detector.stopScan() + statusView.text = "Scan stopped" + scanSummaryView.isVisible = false + } + } + + override fun onResume() { + super.onResume() + ensurePermissionsAndStart() } override fun onDestroy() { super.onDestroy() detector.stopScan() } + + private fun ensurePermissionsAndStart() { + if (hasWifiPermissions()) { + startScanning() + } else { + permissionLauncher.launch(requiredPermissions()) + } + } + + private fun startScanning() { + detector.startScan() + statusView.text = "Scanning for rotor signatures…" + scanSummaryView.isVisible = false + } + + private fun renderScanResults(results: List, snapshot: FlightSnapshot) { + if (results.isEmpty()) { + statusView.text = "No Wi‑Fi beacons detected yet" + scanSummaryView.isVisible = false + return + } + + val strongest = results.sortedBy { it.level }.takeLast(3).reversed() + val summary = strongest.joinToString(separator = "\n") { result -> + val channel = result.frequency + val rssi = result.level + "${result.SSID.ifEmpty { "" }} (${result.BSSID}) - RSSI ${rssi}dBm @${channel}MHz" + } + + val timestamp = snapshot.timestamp.atZone(java.time.ZoneId.systemDefault()) + val header = "Telemetry ${DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(timestamp)} [${snapshot.source}]" + + runOnUiThread { + statusView.text = "Active scan: ${results.size} APs" + scanSummaryView.isVisible = true + scanSummaryView.text = "$header\n$summary" + } + } + + private fun hasWifiPermissions(): Boolean = requiredPermissions().all { perm -> + ActivityCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED + } + + private fun requiredPermissions(): Array { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.NEARBY_WIFI_DEVICES + ) + } else { + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + } + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..0fe801b --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,52 @@ + + + + + + + +