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 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..0641d47
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..02fc4e0
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ DroneDetect
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..b4c21b3
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..a921843
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,21 @@
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:8.2.2"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.buildDir)
+}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..84a0b92
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..64638a1
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,18 @@
+#!/usr/bin/env sh
+
+# Simplified Gradle wrapper for air-gapped builds.
+
+app_path=$(dirname "$0")
+GRADLE_WRAPPER_JAR="$app_path/gradle/wrapper/gradle-wrapper.jar"
+GRADLE_WRAPPER_MAIN="org.gradle.wrapper.GradleWrapperMain"
+
+# Download wrapper jar if missing.
+if [ ! -f "$GRADLE_WRAPPER_JAR" ]; then
+ echo "Gradle wrapper JAR missing: $GRADLE_WRAPPER_JAR"
+ echo "Fetch the wrapper JAR from a trusted host before building."
+ exit 1
+fi
+
+CLASSPATH=$GRADLE_WRAPPER_JAR
+JAVA_OPTS=${JAVA_OPTS:-"-Xmx1g"}
+exec java $JAVA_OPTS -cp "$CLASSPATH" $GRADLE_WRAPPER_MAIN "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..086d2e1
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,17 @@
+@ECHO OFF
+SETLOCAL
+
+SET APP_PATH=%~dp0
+SET WRAPPER_JAR=%APP_PATH%\gradle\wrapper\gradle-wrapper.jar
+SET WRAPPER_MAIN=org.gradle.wrapper.GradleWrapperMain
+
+IF NOT EXIST "%WRAPPER_JAR%" (
+ ECHO Gradle wrapper JAR missing: %WRAPPER_JAR%
+ ECHO Fetch the wrapper JAR from a trusted host before building.
+ EXIT /B 1
+)
+
+SET CLASSPATH=%WRAPPER_JAR%
+SET JAVA_OPTS=%JAVA_OPTS% -Xmx1g
+java %JAVA_OPTS% -cp "%CLASSPATH%" %WRAPPER_MAIN% %*
+ENDLOCAL
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..3092f39
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = "DroneDetectAndroid"
+include(":app")