Initial commit: PilotVault multi-service project

Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

@@ -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,91 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- ── Permissions required by the DJI Mobile SDK ─────────────────────────── -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Biometric / face authentication (local_auth). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- USB host/accessory: DJI remote controllers connect to the phone over USB. -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<uses-feature
android:name="android.hardware.usb.accessory"
android:required="false" />
<application
android:label="PilotVault Fly"
android:name=".DjiApplication"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:usesCleartextTraffic="true">
<!-- DJI MSDK V4 uses the legacy Apache HTTP stack (org.apache.http.*),
which was removed from the default classpath in Android 9 (API 28).
Without this, registerApp() crashes with NoClassDefFoundError on
org.apache.http.params.BasicHttpParams. -->
<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
<!-- DJI App Key. The value is injected from gradle.properties (DJI_API_KEY). -->
<meta-data
android:name="com.dji.sdk.API_KEY"
android:value="${DJI_API_KEY}" />
<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>
<!-- Launch / route the app when a DJI product is attached over USB. -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
android:resource="@xml/accessory_filter" />
</activity>
<!-- 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>
</queries>
</manifest>
@@ -0,0 +1,22 @@
package com.dji.flutter.dji_msdk_sample
import android.app.Application
import android.content.Context
import com.cySdkyc.clx.Helper
/**
* The DJI Mobile SDK V4 relocates and lazily loads its classes through the
* Secneo [Helper]. It MUST be installed in [attachBaseContext] — before any
* DJI class is touched — otherwise SDK registration crashes with a
* NoClassDefFoundError / UnsatisfiedLinkError.
*
* Registered in AndroidManifest.xml via android:name=".DjiApplication".
*/
class DjiApplication : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
// Unpacks and prepares the DJI SDK native/dex payload.
Helper.install(this)
}
}
@@ -0,0 +1,189 @@
package com.dji.flutter.dji_msdk_sample
import android.content.Context
import android.os.Handler
import android.os.Looper
import dji.common.battery.BatteryState
import dji.common.error.DJIError
import dji.common.error.DJISDKError
import dji.common.flightcontroller.FlightControllerState
import dji.sdk.base.BaseComponent
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import dji.sdk.sdkmanager.DJISDKInitEvent
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Bridges the DJI Mobile SDK V4 to Flutter.
*
* - [METHOD_CHANNEL] handles imperative calls from Dart (register, connect, query).
* - [EVENT_CHANNEL] streams registration / connection / telemetry updates to Dart.
*
* All SDK callbacks arrive on arbitrary threads, so every event is marshalled to
* the main thread before being pushed into the Flutter [EventChannel.EventSink].
*/
class DjiSdkBridge(
private val appContext: Context,
messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
companion object {
private const val METHOD_CHANNEL = "dji_msdk/methods"
private const val EVENT_CHANNEL = "dji_msdk/events"
}
private val mainHandler = Handler(Looper.getMainLooper())
private val methodChannel = MethodChannel(messenger, METHOD_CHANNEL)
private val eventChannel = EventChannel(messenger, EVENT_CHANNEL)
private var eventSink: EventChannel.EventSink? = null
init {
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
}
// ── MethodChannel ──────────────────────────────────────────────────────────
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getSdkVersion" ->
result.success(DJISDKManager.getInstance().sdkVersion)
"registerApp" -> {
registerApp()
result.success(null)
}
"startConnection" ->
result.success(DJISDKManager.getInstance().startConnectionToProduct())
"stopConnection" -> {
DJISDKManager.getInstance().stopConnectionToProduct()
result.success(null)
}
"getProductInfo" ->
result.success(connectionMap(DJISDKManager.getInstance().product))
else -> result.notImplemented()
}
}
// ── EventChannel ───────────────────────────────────────────────────────────
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
private fun emit(event: Map<String, Any?>) {
mainHandler.post { eventSink?.success(event) }
}
// ── DJI SDK registration & product lifecycle ─────────────────────────────────
private fun registerApp() {
emit(mapOf("type" to "registration", "state" to "registering"))
DJISDKManager.getInstance().registerApp(
appContext,
object : DJISDKManager.SDKManagerCallback {
override fun onRegister(error: DJIError?) {
if (error == DJISDKError.REGISTRATION_SUCCESS) {
emit(mapOf("type" to "registration", "state" to "success"))
// Begin scanning for an attached product (USB RC / Wi-Fi).
DJISDKManager.getInstance().startConnectionToProduct()
} else {
emit(
mapOf(
"type" to "registration",
"state" to "failed",
"error" to (error?.description ?: "Unknown registration error"),
)
)
}
}
override fun onProductConnect(product: BaseProduct?) {
emit(connectionMap(product))
bindComponentCallbacks(product)
}
override fun onProductChanged(product: BaseProduct?) {
emit(connectionMap(product))
bindComponentCallbacks(product)
}
override fun onProductDisconnect() {
emit(mapOf("type" to "connection", "connected" to false, "model" to null))
}
override fun onComponentChange(
key: BaseProduct.ComponentKey?,
oldComponent: BaseComponent?,
newComponent: BaseComponent?,
) {
// A component (e.g. flight controller, battery) appeared/changed —
// (re)attach the telemetry callbacks.
bindComponentCallbacks(DJISDKManager.getInstance().product)
}
override fun onInitProcess(event: DJISDKInitEvent?, totalProcess: Int) {
emit(mapOf("type" to "init", "event" to event?.toString()))
}
override fun onDatabaseDownloadProgress(current: Long, total: Long) {
emit(mapOf("type" to "database", "current" to current, "total" to total))
}
},
)
}
private fun connectionMap(product: BaseProduct?): Map<String, Any?> {
val connected = product != null && product.isConnected
val model = product?.model?.displayName
return mapOf("type" to "connection", "connected" to connected, "model" to model)
}
/** Attaches flight-controller and battery state listeners when on an aircraft. */
private fun bindComponentCallbacks(product: BaseProduct?) {
if (product !is Aircraft) return
product.flightController?.setStateCallback { state: FlightControllerState ->
val location = state.aircraftLocation
emit(
mapOf(
"type" to "telemetry",
"satelliteCount" to state.satelliteCount,
"isFlying" to state.isFlying,
"flightMode" to state.flightModeString,
"altitude" to location?.altitude,
"latitude" to location?.latitude,
"longitude" to location?.longitude,
"velocityX" to state.velocityX,
"velocityY" to state.velocityY,
"velocityZ" to state.velocityZ,
)
)
}
@Suppress("DEPRECATION")
product.battery?.setStateCallback { batteryState: BatteryState ->
emit(
mapOf(
"type" to "battery",
"percent" to batteryState.chargeRemainingInPercent,
)
)
}
}
}
@@ -0,0 +1,87 @@
package com.dji.flutter.dji_msdk_sample
import android.content.Context
import android.graphics.SurfaceTexture
import android.view.TextureView
import android.view.View
import dji.sdk.camera.VideoFeeder
import dji.sdk.codec.DJICodecManager
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
/**
* Flutter [PlatformView] that renders the DJI product's live H.264 primary video
* feed as a full-bleed background for the Flight Control HUD.
*
* Pipeline: a [TextureView]'s [SurfaceTexture] is handed to a [DJICodecManager]
* (hardware decoder). [VideoFeeder]'s primary-feed data listener pushes raw
* frames straight into the decoder, which renders onto the surface.
*
* Registered under [VIEW_TYPE] in `MainActivity.configureFlutterEngine`; embedded
* on the Dart side by `AndroidView(viewType: 'dji_msdk/video')`.
*
* Note: this uses the *primary* video feed, which is correct for the vast
* majority of products. A few older transcoding models (e.g. Mavic Pro) expose
* their live view only on the transcoded feed — if such a product renders black,
* switch to `VideoFeeder.getInstance().provideTranscodedVideoFeed()`.
*/
class DjiVideoView(context: Context) : PlatformView, TextureView.SurfaceTextureListener {
companion object {
const val VIEW_TYPE = "dji_msdk/video"
}
private val textureView = TextureView(context).also {
it.surfaceTextureListener = this
}
private var codecManager: DJICodecManager? = null
// Pushes raw H.264 frames from the SDK straight into the hardware decoder.
private val videoDataListener = VideoFeeder.VideoDataListener { data, size ->
codecManager?.sendDataToDecoder(data, size)
}
override fun getView(): View = textureView
override fun dispose() {
teardown()
}
// ── TextureView.SurfaceTextureListener ───────────────────────────────────
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
if (codecManager == null) {
codecManager = DJICodecManager(textureView.context, surface, width, height)
}
VideoFeeder.getInstance()?.primaryVideoFeed?.addVideoDataListener(videoDataListener)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
codecManager?.onSurfaceSizeChanged(width, height, 0)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
teardown()
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
/** Detaches the feed listener and releases the decoder + its surface. */
private fun teardown() {
VideoFeeder.getInstance()?.primaryVideoFeed?.removeVideoDataListener(videoDataListener)
codecManager?.let {
it.cleanSurface()
it.destroyCodec()
}
codecManager = null
}
}
/** Builds a [DjiVideoView] for each `AndroidView(viewType: 'dji_msdk/video')`. */
class DjiVideoViewFactory : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView =
DjiVideoView(context)
}
@@ -0,0 +1,58 @@
package com.dji.flutter.dji_msdk_sample
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
// FlutterFragmentActivity (not FlutterActivity) is required by local_auth so the
// platform BiometricPrompt can attach to a FragmentActivity host.
class MainActivity : FlutterFragmentActivity() {
private var bridge: DjiSdkBridge? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
bridge = DjiSdkBridge(applicationContext, flutterEngine.dartExecutor.binaryMessenger)
// Live DJI video feed rendered behind the Flight Control HUD.
flutterEngine.platformViewsController.registry
.registerViewFactory(DjiVideoView.VIEW_TYPE, DjiVideoViewFactory())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestDjiPermissions()
}
/**
* The DJI SDK needs these dangerous permissions granted before it can connect
* to / communicate with a product. We request them up front from the host
* Activity so the Flutter UI can stay focused on the SDK flow.
*/
private fun requestDjiPermissions() {
val missing = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
ActivityCompat.requestPermissions(this, missing.toTypedArray(), PERMISSION_REQUEST_CODE)
}
}
companion object {
private const val PERMISSION_REQUEST_CODE = 12321
private val REQUIRED_PERMISSIONS = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
add(Manifest.permission.READ_PHONE_STATE)
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}.toTypedArray()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0F1E3D</color>
</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,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- DJI remote controllers / aircraft enumerated as USB accessories. -->
<usb-accessory model="T600" manufacturer="DJI" />
<usb-accessory model="AG410" manufacturer="DJI" />
<usb-accessory model="com.dji.logiclink" manufacturer="DJI" />
<usb-accessory model="WM160" manufacturer="DJI" />
<usb-accessory model="WM160_FCC" manufacturer="DJI" />
</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>