Add the Fly Agent App, a Remote ID companion to DJI Fly
A Flutter app that runs next to DJI Fly without the DJI SDK. It receives the aircraft's Remote ID (Bluetooth, BT5, Wi-Fi NaN and Beacon) in a foreground service, streams the paired aircraft to the hub over /ws/device, and turns take-off to landing into a logbook entry filed through /api/flights, queued when offline. Covers phases A0-A3 of the Fly Agent plan; needs no server change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
415c6184b1
commit
3dd31b257a
55 files changed
+5716
No files matched your search
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.pilotvault.flyagent"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.pilotvault.flyagent"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,76 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Remote ID over Bluetooth LE. BLUETOOTH_SCAN deliberately has no
|
||||
neverForLocation flag: the scan results ARE used to derive locations,
|
||||
and that flag makes Android strip beacon advertisements. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
|
||||
<!-- Remote ID over Wi-Fi Beacon (scan results) and Wi-Fi NaN (Aware). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
|
||||
<!-- Phone GPS (pilot position) and, below Android 12/13, the gate for BLE/Wi-Fi scanning. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Keeps scanning while DJI Fly is in front. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="PV Fly Agent"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".RidScanService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- To offer "Open DJI Fly" from the session screen. -->
|
||||
<package android:name="dji.go.v5" />
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.pilotvault.flyagent
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.ParcelUuid
|
||||
|
||||
/**
|
||||
* Remote ID over Bluetooth LE (ASTM F3411 / ASD-STAN EN 4709-002).
|
||||
*
|
||||
* The broadcast is service data under the 16-bit UUID 0xFFFA whose first byte
|
||||
* is the application code 0x0D, then a message counter, then either one 25-byte
|
||||
* message (BT4 legacy advertising) or a message pack (BT5 extended / Long Range).
|
||||
*/
|
||||
class BleRidScanner(private val context: Context) {
|
||||
companion object {
|
||||
const val TRANSPORT_LEGACY = "ble"
|
||||
const val TRANSPORT_EXTENDED = "ble5"
|
||||
private val SERVICE_UUID: ParcelUuid = ParcelUuid.fromString("0000fffa-0000-1000-8000-00805f9b34fb")
|
||||
private const val APP_CODE: Byte = 0x0D
|
||||
}
|
||||
|
||||
private var running = false
|
||||
|
||||
private val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) = handle(result)
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) = results.forEach(::handle)
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
running = false
|
||||
RidHub.status(TRANSPORT_LEGACY, "failed", "scan error $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
fun capabilities(): Map<String, Boolean> {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
val extended = adapter != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
adapter.isLeExtendedAdvertisingSupported
|
||||
val coded = adapter != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
adapter.isLeCodedPhySupported
|
||||
return mapOf("ble" to (adapter != null), "bleExtended" to extended, "bleCoded" to coded)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start() {
|
||||
if (running) return
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
if (adapter == null) {
|
||||
RidHub.status(TRANSPORT_LEGACY, "unavailable", "no Bluetooth adapter")
|
||||
return
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
RidHub.status(TRANSPORT_LEGACY, "unavailable", "Bluetooth is off")
|
||||
return
|
||||
}
|
||||
val scanner = adapter.bluetoothLeScanner ?: run {
|
||||
RidHub.status(TRANSPORT_LEGACY, "unavailable", "no LE scanner")
|
||||
return
|
||||
}
|
||||
// A filter is what lets Android keep scanning with the screen off.
|
||||
val filter = ScanFilter.Builder()
|
||||
.setServiceData(SERVICE_UUID, byteArrayOf(APP_CODE), byteArrayOf(0xFF.toByte()))
|
||||
.build()
|
||||
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && adapter.isLeExtendedAdvertisingSupported) {
|
||||
// Also receive BT5 extended advertising (and Coded PHY where the radio has it).
|
||||
settings.setLegacy(false).setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED)
|
||||
}
|
||||
try {
|
||||
scanner.startScan(listOf(filter), settings.build(), callback)
|
||||
running = true
|
||||
RidHub.status(TRANSPORT_LEGACY, "scanning")
|
||||
} catch (e: SecurityException) {
|
||||
RidHub.status(TRANSPORT_LEGACY, "denied", e.message ?: "Bluetooth scan permission missing")
|
||||
} catch (e: IllegalStateException) {
|
||||
RidHub.status(TRANSPORT_LEGACY, "unavailable", e.message ?: "Bluetooth is off")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
running = false
|
||||
try {
|
||||
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
||||
?.adapter?.bluetoothLeScanner?.stopScan(callback)
|
||||
} catch (_: Exception) {
|
||||
// Bluetooth was switched off underneath us; nothing left to stop.
|
||||
}
|
||||
RidHub.status(TRANSPORT_LEGACY, "stopped")
|
||||
}
|
||||
|
||||
private fun handle(result: ScanResult) {
|
||||
val data = result.scanRecord?.getServiceData(SERVICE_UUID) ?: return
|
||||
// [app code][counter][message or pack]
|
||||
if (data.size < 3 || data[0] != APP_CODE) return
|
||||
val transport = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !result.isLegacy) {
|
||||
TRANSPORT_EXTENDED
|
||||
} else {
|
||||
TRANSPORT_LEGACY
|
||||
}
|
||||
RidHub.frame(transport, result.device.address, result.rssi, data.copyOfRange(2, data.size))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.pilotvault.flyagent
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Bridges the Remote ID receiver to Dart:
|
||||
* * `pilotvault/rid` (methods): capabilities / start / stop / isRunning,
|
||||
* battery-optimisation exemption, launching DJI Fly.
|
||||
* * `pilotvault/rid/events`: raw frames and scanner status from [RidHub].
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private const val DJI_FLY_PACKAGE = "dji.go.v5"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
EventChannel(messenger, "pilotvault/rid/events").setStreamHandler(RidHub)
|
||||
MethodChannel(messenger, "pilotvault/rid").setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"capabilities" -> result.success(
|
||||
BleRidScanner(this).capabilities() + WifiRidScanner(this).capabilities()
|
||||
)
|
||||
"start" -> {
|
||||
RidScanService.start(this)
|
||||
result.success(true)
|
||||
}
|
||||
"stop" -> {
|
||||
RidScanService.stop(this)
|
||||
result.success(true)
|
||||
}
|
||||
"isRunning" -> result.success(RidScanService.running)
|
||||
"isIgnoringBatteryOptimizations" -> result.success(ignoringBatteryOptimizations())
|
||||
"requestIgnoreBatteryOptimizations" -> {
|
||||
requestIgnoreBatteryOptimizations()
|
||||
result.success(null)
|
||||
}
|
||||
"openDjiFly" -> result.success(openDjiFly())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ignoringBatteryOptimizations(): Boolean {
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(packageName)
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun requestIgnoreBatteryOptimizations() {
|
||||
if (ignoringBatteryOptimizations()) return
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
|
||||
private fun openDjiFly(): Boolean {
|
||||
val intent = packageManager.getLaunchIntentForPackage(DJI_FLY_PACKAGE) ?: return false
|
||||
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.pilotvault.flyagent
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
||||
/**
|
||||
* Hands raw Remote ID frames from the scanners to Dart.
|
||||
*
|
||||
* The scanners live in [RidScanService] and outlive the Flutter activity's
|
||||
* event channel listener, so they post here and this drops frames while no
|
||||
* one is listening. Decoding happens in Dart (`lib/rid/odid_decoder.dart`):
|
||||
* the scanners only strip the transport framing down to the first OpenDroneID
|
||||
* byte, so the decoder sees the same bytes whichever transport carried them.
|
||||
*/
|
||||
object RidHub : EventChannel.StreamHandler {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
@Volatile private var sink: EventChannel.EventSink? = null
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
sink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sink = null
|
||||
}
|
||||
|
||||
/** One received broadcast. [payload] starts at an ODID message or message pack. */
|
||||
fun frame(transport: String, source: String, rssi: Int, payload: ByteArray) {
|
||||
emit(
|
||||
mapOf(
|
||||
"kind" to "frame",
|
||||
"transport" to transport,
|
||||
"source" to source,
|
||||
"rssi" to rssi,
|
||||
"ts" to System.currentTimeMillis(),
|
||||
"payload" to payload,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** A scanner started, stopped or failed; shown on the receiver's debug screen. */
|
||||
fun status(transport: String, state: String, detail: String = "") {
|
||||
emit(mapOf("kind" to "status", "transport" to transport, "state" to state, "detail" to detail))
|
||||
}
|
||||
|
||||
private fun emit(event: Map<String, Any>) {
|
||||
main.post { sink?.success(event) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.pilotvault.flyagent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* Foreground service that owns the Remote ID scanners, so they keep running
|
||||
* (and the process stays alive for the Dart uplink) while DJI Fly is in front.
|
||||
*/
|
||||
class RidScanService : Service() {
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "rid_scan"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
@Volatile var running = false
|
||||
private set
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, RidScanService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, RidScanService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var ble: BleRidScanner
|
||||
private lateinit var wifi: WifiRidScanner
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ble = BleRidScanner(this)
|
||||
wifi = WifiRidScanner(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
if (!running) {
|
||||
running = true
|
||||
ble.start()
|
||||
wifi.start()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
running = false
|
||||
ble.stop()
|
||||
wifi.stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Remote ID receiver", NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setContentTitle("PilotVault Fly Agent")
|
||||
.setContentText("Listening for Remote ID")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.pilotvault.flyagent
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.net.wifi.aware.AttachCallback
|
||||
import android.net.wifi.aware.DiscoverySessionCallback
|
||||
import android.net.wifi.aware.PeerHandle
|
||||
import android.net.wifi.aware.SubscribeConfig
|
||||
import android.net.wifi.aware.SubscribeDiscoverySession
|
||||
import android.net.wifi.aware.WifiAwareManager
|
||||
import android.net.wifi.aware.WifiAwareSession
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Remote ID over Wi-Fi: NaN (Neighbour Awareness Networking) and Beacon.
|
||||
*
|
||||
* NaN: a subscribe session for the service "org.opendroneid.remoteid"; the
|
||||
* service-specific info is [counter][message pack].
|
||||
*
|
||||
* Beacon: a vendor-specific information element (id 221) with the ASD-STAN OUI
|
||||
* FA:0B:BC and OUI type 0x0D, then [counter][message pack]. Information elements
|
||||
* are only exposed from Android 11, and Android throttles foreground Wi-Fi scans
|
||||
* to four per two minutes unless the user turns throttling off in developer
|
||||
* options, so Beacon gives sparse updates. Results from other apps' scans are
|
||||
* picked up too.
|
||||
*/
|
||||
class WifiRidScanner(private val context: Context) {
|
||||
companion object {
|
||||
const val TRANSPORT_NAN = "nan"
|
||||
const val TRANSPORT_BEACON = "beacon"
|
||||
private const val NAN_SERVICE = "org.opendroneid.remoteid"
|
||||
private const val IE_VENDOR_SPECIFIC = 221
|
||||
private val BEACON_OUI = byteArrayOf(0xFA.toByte(), 0x0B, 0xBC.toByte(), 0x0D)
|
||||
private const val BEACON_SCAN_INTERVAL_MS = 30_000L
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
private var awareSession: WifiAwareSession? = null
|
||||
private var subscribe: SubscribeDiscoverySession? = null
|
||||
private var beaconRunning = false
|
||||
|
||||
fun capabilities(): Map<String, Boolean> = mapOf(
|
||||
"nan" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE)),
|
||||
"beacon" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R),
|
||||
)
|
||||
|
||||
fun start() {
|
||||
startNan()
|
||||
startBeacon()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopNan()
|
||||
stopBeacon()
|
||||
}
|
||||
|
||||
// ── NaN ────────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startNan() {
|
||||
if (awareSession != null) return
|
||||
if (capabilities()["nan"] != true) {
|
||||
RidHub.status(TRANSPORT_NAN, "unavailable", "this phone has no Wi-Fi Aware")
|
||||
return
|
||||
}
|
||||
val manager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? WifiAwareManager
|
||||
if (manager == null || !manager.isAvailable) {
|
||||
RidHub.status(TRANSPORT_NAN, "unavailable", "Wi-Fi Aware is off (Wi-Fi or location off?)")
|
||||
return
|
||||
}
|
||||
try {
|
||||
manager.attach(object : AttachCallback() {
|
||||
override fun onAttached(session: WifiAwareSession) {
|
||||
awareSession = session
|
||||
val config = SubscribeConfig.Builder()
|
||||
.setServiceName(NAN_SERVICE)
|
||||
.setSubscribeType(SubscribeConfig.SUBSCRIBE_TYPE_PASSIVE)
|
||||
.build()
|
||||
session.subscribe(config, object : DiscoverySessionCallback() {
|
||||
override fun onSubscribeStarted(session: SubscribeDiscoverySession) {
|
||||
subscribe = session
|
||||
RidHub.status(TRANSPORT_NAN, "scanning")
|
||||
}
|
||||
|
||||
override fun onServiceDiscovered(
|
||||
peerHandle: PeerHandle,
|
||||
serviceSpecificInfo: ByteArray?,
|
||||
matchFilter: MutableList<ByteArray>?,
|
||||
) {
|
||||
val info = serviceSpecificInfo ?: return
|
||||
if (info.size < 2) return
|
||||
// NaN hides the peer's MAC; the handle is stable for the session.
|
||||
RidHub.frame(TRANSPORT_NAN, "nan-${peerHandle.hashCode()}", 0, info.copyOfRange(1, info.size))
|
||||
}
|
||||
|
||||
override fun onSessionTerminated() {
|
||||
subscribe = null
|
||||
RidHub.status(TRANSPORT_NAN, "stopped", "session terminated")
|
||||
}
|
||||
}, main)
|
||||
}
|
||||
|
||||
override fun onAttachFailed() {
|
||||
RidHub.status(TRANSPORT_NAN, "failed", "attach failed")
|
||||
}
|
||||
}, main)
|
||||
} catch (e: SecurityException) {
|
||||
RidHub.status(TRANSPORT_NAN, "denied", e.message ?: "nearby Wi-Fi permission missing")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopNan() {
|
||||
subscribe?.close()
|
||||
subscribe = null
|
||||
if (awareSession != null) {
|
||||
awareSession?.close()
|
||||
awareSession = null
|
||||
RidHub.status(TRANSPORT_NAN, "stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Beacon ─────────────────────────────────────────────────────────────
|
||||
|
||||
private val scanReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) = readScanResults()
|
||||
}
|
||||
|
||||
private val scanTick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!beaconRunning) return
|
||||
@Suppress("DEPRECATION")
|
||||
try {
|
||||
wifi.startScan()
|
||||
} catch (_: SecurityException) {
|
||||
}
|
||||
main.postDelayed(this, BEACON_SCAN_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBeacon() {
|
||||
if (beaconRunning) return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
RidHub.status(TRANSPORT_BEACON, "unavailable", "needs Android 11")
|
||||
return
|
||||
}
|
||||
if (!wifi.isWifiEnabled && !wifi.isScanAlwaysAvailable) {
|
||||
RidHub.status(TRANSPORT_BEACON, "unavailable", "Wi-Fi is off")
|
||||
return
|
||||
}
|
||||
context.registerReceiver(scanReceiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
|
||||
beaconRunning = true
|
||||
main.post(scanTick)
|
||||
RidHub.status(TRANSPORT_BEACON, "scanning", "every ${BEACON_SCAN_INTERVAL_MS / 1000} s (Android throttles Wi-Fi scans)")
|
||||
}
|
||||
|
||||
private fun stopBeacon() {
|
||||
if (!beaconRunning) return
|
||||
beaconRunning = false
|
||||
main.removeCallbacks(scanTick)
|
||||
try {
|
||||
context.unregisterReceiver(scanReceiver)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
RidHub.status(TRANSPORT_BEACON, "stopped")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readScanResults() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
val results = try {
|
||||
wifi.scanResults
|
||||
} catch (e: SecurityException) {
|
||||
RidHub.status(TRANSPORT_BEACON, "denied", e.message ?: "location permission missing")
|
||||
return
|
||||
}
|
||||
for (r in results) {
|
||||
for (ie in r.informationElements) {
|
||||
if (ie.id != IE_VENDOR_SPECIFIC) continue
|
||||
val buf = ie.bytes
|
||||
val bytes = ByteArray(buf.remaining()).also { buf.duplicate().get(it) }
|
||||
// [OUI x3][OUI type][counter][message pack]
|
||||
if (bytes.size < BEACON_OUI.size + 2) continue
|
||||
if (!bytes.copyOfRange(0, BEACON_OUI.size).contentEquals(BEACON_OUI)) continue
|
||||
RidHub.frame(TRANSPORT_BEACON, r.BSSID ?: "", r.level, bytes.copyOfRange(BEACON_OUI.size + 1, bytes.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
|
||||
# The project (E:) and the pub cache (C:) sit on different drives, which breaks
|
||||
# Kotlin incremental caches ("Could not close incremental caches").
|
||||
kotlin.incremental=false
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
Reference in new issue
Block a user