From 43c82c053e18469cb97998fa7b68febf594376a3 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 18 Apr 2026 21:52:43 +0100 Subject: [PATCH 1/3] test(backend): fix one failing backend test due to db conn --- src/backend/app/arq/tasks.py | 3 +++ src/backend/tests/test_project_processing.py | 13 +------------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/backend/app/arq/tasks.py b/src/backend/app/arq/tasks.py index d84df260..87fa99be 100644 --- a/src/backend/app/arq/tasks.py +++ b/src/backend/app/arq/tasks.py @@ -673,6 +673,7 @@ async def move_task_images_for_processing( "Please retry by marking this task as fully flown again. " f"Details: {str(e)}" ) + conn = None try: async with db_pool.connection() as conn: transition = await task_logic.update_task_state_system( @@ -687,6 +688,8 @@ async def move_task_images_for_processing( if transition is not None: await conn.commit() except Exception as state_error: + if conn is not None: + await conn.rollback() log.error( f"Failed to persist transfer failure state for task {task_id}: " f"{state_error}" diff --git a/src/backend/tests/test_project_processing.py b/src/backend/tests/test_project_processing.py index 0cfddc84..559aa16d 100644 --- a/src/backend/tests/test_project_processing.py +++ b/src/backend/tests/test_project_processing.py @@ -48,17 +48,6 @@ def connection(self): return _FakePoolConnection(self.conn) -class _FakeDbPoolContext: - def __init__(self, conn): - self.conn = conn - - async def __aenter__(self): - return _FakePool(self.conn) - - async def __aexit__(self, exc_type, exc, tb): - return False - - class _FakeZipFile: def __init__(self, *args, **kwargs): self._members = ["odm_orthophoto/odm_orthophoto.tif", "odm_report/report.pdf"] @@ -523,7 +512,7 @@ def get_task(self, odm_task_id): return FakeTask() async def fake_get_db_connection_pool(): - return _FakeDbPoolContext(conn) + return _FakePool(conn) async def fake_update_task_state_system(**kwargs): return { From bfe3b2aaf6988d2d79799221cc0e086f26a85b2f Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 18 Apr 2026 22:59:00 +0100 Subject: [PATCH 2/3] feat: init a flutter file transfer util to copy waypoint files Assisted by: Opus 4.6 LLM --- src/transfer-util/.gitignore | 48 +++ src/transfer-util/.metadata | 30 ++ src/transfer-util/README.md | 17 + src/transfer-util/analysis_options.yaml | 28 ++ src/transfer-util/android/.gitignore | 14 + .../android/app/build.gradle.kts | 41 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 83 ++++ .../hotosm/dronetm_transfer/MainActivity.kt | 56 +++ .../dronetm_transfer/MtpTransferPlugin.kt | 400 ++++++++++++++++++ .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/main/res/xml/device_filter.xml | 7 + .../app/src/profile/AndroidManifest.xml | 7 + src/transfer-util/android/build.gradle.kts | 24 ++ src/transfer-util/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + src/transfer-util/android/settings.gradle.kts | 26 ++ src/transfer-util/build.sh | 19 + src/transfer-util/plan.md | 360 ++++++++++++++++ src/transfer-util/pubspec.lock | 314 ++++++++++++++ src/transfer-util/pubspec.yaml | 21 + src/transfer-util/test/widget_test.dart | 10 + 30 files changed, 1579 insertions(+) create mode 100644 src/transfer-util/.gitignore create mode 100644 src/transfer-util/.metadata create mode 100644 src/transfer-util/README.md create mode 100644 src/transfer-util/analysis_options.yaml create mode 100644 src/transfer-util/android/.gitignore create mode 100644 src/transfer-util/android/app/build.gradle.kts create mode 100644 src/transfer-util/android/app/src/debug/AndroidManifest.xml create mode 100644 src/transfer-util/android/app/src/main/AndroidManifest.xml create mode 100644 src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt create mode 100644 src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt create mode 100644 src/transfer-util/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 src/transfer-util/android/app/src/main/res/drawable/launch_background.xml create mode 100644 src/transfer-util/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/transfer-util/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/transfer-util/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/transfer-util/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/transfer-util/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/transfer-util/android/app/src/main/res/values-night/styles.xml create mode 100644 src/transfer-util/android/app/src/main/res/values/styles.xml create mode 100644 src/transfer-util/android/app/src/main/res/xml/device_filter.xml create mode 100644 src/transfer-util/android/app/src/profile/AndroidManifest.xml create mode 100644 src/transfer-util/android/build.gradle.kts create mode 100644 src/transfer-util/android/gradle.properties create mode 100644 src/transfer-util/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 src/transfer-util/android/settings.gradle.kts create mode 100755 src/transfer-util/build.sh create mode 100644 src/transfer-util/plan.md create mode 100644 src/transfer-util/pubspec.lock create mode 100644 src/transfer-util/pubspec.yaml create mode 100644 src/transfer-util/test/widget_test.dart diff --git a/src/transfer-util/.gitignore b/src/transfer-util/.gitignore new file mode 100644 index 00000000..e8ea5722 --- /dev/null +++ b/src/transfer-util/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Builds +*.apk diff --git a/src/transfer-util/.metadata b/src/transfer-util/.metadata new file mode 100644 index 00000000..28fecb63 --- /dev/null +++ b/src/transfer-util/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + - platform: android + create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/src/transfer-util/README.md b/src/transfer-util/README.md new file mode 100644 index 00000000..2a97de08 --- /dev/null +++ b/src/transfer-util/README.md @@ -0,0 +1,17 @@ +# dronetm_transfer + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/src/transfer-util/analysis_options.yaml b/src/transfer-util/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/src/transfer-util/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/src/transfer-util/android/.gitignore b/src/transfer-util/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/src/transfer-util/android/.gitignore @@ -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 diff --git a/src/transfer-util/android/app/build.gradle.kts b/src/transfer-util/android/app/build.gradle.kts new file mode 100644 index 00000000..4a06368a --- /dev/null +++ b/src/transfer-util/android/app/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "org.hotosm.dronetm_transfer" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "org.hotosm.drone_tm" + minSdk = 24 + 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") + } + } +} + +flutter { + source = "../.." +} diff --git a/src/transfer-util/android/app/src/debug/AndroidManifest.xml b/src/transfer-util/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/src/transfer-util/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/src/transfer-util/android/app/src/main/AndroidManifest.xml b/src/transfer-util/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..77864ca8 --- /dev/null +++ b/src/transfer-util/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt new file mode 100644 index 00000000..22ec638e --- /dev/null +++ b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt @@ -0,0 +1,56 @@ +package org.hotosm.dronetm_transfer + +import android.content.Intent +import android.hardware.usb.UsbManager +import android.os.Bundle +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterActivity() { + + private lateinit var mtpPlugin: MtpTransferPlugin + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + mtpPlugin = MtpTransferPlugin() + mtpPlugin.register(this, flutterEngine) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + when (intent.action) { + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + mtpPlugin.onUsbDeviceAttached() + } + Intent.ACTION_SEND -> { + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (uri != null) { + mtpPlugin.onFileReceived(uri.toString()) + } + } + Intent.ACTION_VIEW -> { + val uri = intent.data + if (uri != null) { + if (uri.scheme == "dronetm") { + val fileUri = uri.getQueryParameter("file") + if (fileUri != null) { + mtpPlugin.onFileReceived(fileUri) + } + } else { + mtpPlugin.onFileReceived(uri.toString()) + } + } + } + } + } +} diff --git a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt new file mode 100644 index 00000000..e6385b17 --- /dev/null +++ b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt @@ -0,0 +1,400 @@ +package org.hotosm.dronetm_transfer + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.mtp.MtpConstants +import android.mtp.MtpDevice +import android.mtp.MtpObjectInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.File +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class MtpTransferPlugin : MethodChannel.MethodCallHandler { + + companion object { + private const val TAG = "MtpTransfer" + private const val METHOD_CHANNEL = "org.hotosm.drone_tm/mtp" + private const val EVENT_CHANNEL = "org.hotosm.drone_tm/mtp_events" + private const val ACTION_USB_PERMISSION = "org.hotosm.drone_tm.USB_PERMISSION" + + // DJI vendor ID + private const val DJI_VENDOR_ID = 11427 + + // DJI waypoint path segments + private val DJI_PATH_SEGMENTS = listOf("Android", "data", "dji.go.v5", "files", "waypoint") + } + + private lateinit var activity: Activity + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + private var currentMtpDevice: MtpDevice? = null + + private val usbReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_USB_PERMISSION -> { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (granted && device != null) { + sendEvent("usb_permission_granted", mapOf("deviceName" to (device.productName ?: "Unknown"))) + } else { + sendEvent("usb_permission_denied", null) + } + } + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + closeMtpDevice() + sendEvent("device_disconnected", null) + } + } + } + } + + fun register(activity: Activity, flutterEngine: FlutterEngine) { + this.activity = activity + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + override fun onCancel(arguments: Any?) { + eventSink = null + } + }) + + val filter = IntentFilter().apply { + addAction(ACTION_USB_PERMISSION) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + activity.registerReceiver(usbReceiver, filter) + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getConnectedDevices" -> getConnectedDevices(result) + "requestPermission" -> { + val deviceName = call.argument("deviceName") + requestPermission(deviceName, result) + } + "openDevice" -> { + val deviceName = call.argument("deviceName") + openDevice(deviceName, result) + } + "listMissions" -> listMissions(result) + "transferKmz" -> { + val uuid = call.argument("uuid") ?: return result.error("INVALID_ARGS", "uuid required", null) + val kmzData = call.argument("kmzData") ?: return result.error("INVALID_ARGS", "kmzData required", null) + transferKmz(uuid, kmzData, result) + } + "closeDevice" -> { + closeMtpDevice() + result.success(true) + } + "getDeviceStatus" -> getDeviceStatus(result) + else -> result.notImplemented() + } + } + + fun onUsbDeviceAttached() { + sendEvent("device_attached", null) + } + + fun onFileReceived(fileUri: String) { + sendEvent("file_received", mapOf("uri" to fileUri)) + } + + private fun getConnectedDevices(result: MethodChannel.Result) { + val usbManager = activity.getSystemService(Context.USB_SERVICE) as UsbManager + val devices = usbManager.deviceList.values + .filter { it.vendorId == DJI_VENDOR_ID || isMtpDevice(it) } + .map { device -> + mapOf( + "name" to (device.productName ?: "USB Device"), + "vendorId" to device.vendorId, + "productId" to device.productId, + "hasPermission" to usbManager.hasPermission(device), + "isDji" to (device.vendorId == DJI_VENDOR_ID) + ) + } + result.success(devices) + } + + private fun requestPermission(deviceName: String?, result: MethodChannel.Result) { + val usbManager = activity.getSystemService(Context.USB_SERVICE) as UsbManager + val device = findDevice(usbManager, deviceName) + if (device == null) { + result.error("DEVICE_NOT_FOUND", "No matching USB device found", null) + return + } + if (usbManager.hasPermission(device)) { + result.success(true) + return + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val permissionIntent = PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), flags) + usbManager.requestPermission(device, permissionIntent) + result.success(false) // Permission dialog shown, result comes via event + } + + private fun openDevice(deviceName: String?, result: MethodChannel.Result) { + val usbManager = activity.getSystemService(Context.USB_SERVICE) as UsbManager + val device = findDevice(usbManager, deviceName) + if (device == null) { + result.error("DEVICE_NOT_FOUND", "No matching USB device found", null) + return + } + if (!usbManager.hasPermission(device)) { + result.error("NO_PERMISSION", "USB permission not granted", null) + return + } + try { + closeMtpDevice() + val mtpDevice = MtpDevice(device) + val connection = usbManager.openDevice(device) + if (connection == null) { + result.error("OPEN_FAILED", "Failed to open USB connection", null) + return + } + if (!mtpDevice.open(connection)) { + result.error("MTP_OPEN_FAILED", "Failed to open MTP device", null) + return + } + currentMtpDevice = mtpDevice + val deviceInfo = mtpDevice.deviceInfo + result.success(mapOf( + "name" to (device.productName ?: "Unknown"), + "manufacturer" to (deviceInfo?.manufacturer ?: "Unknown"), + "model" to (deviceInfo?.model ?: "Unknown"), + "serialNumber" to (deviceInfo?.serialNumber ?: "Unknown") + )) + } catch (e: Exception) { + Log.e(TAG, "Failed to open MTP device", e) + result.error("MTP_ERROR", "Failed to open MTP device: ${e.message}", null) + } + } + + private fun listMissions(result: MethodChannel.Result) { + val device = currentMtpDevice + if (device == null) { + result.error("NOT_CONNECTED", "No MTP device connected", null) + return + } + try { + val waypointHandle = navigateToWaypointDir(device) + if (waypointHandle == null) { + result.success(emptyList>()) + return + } + + val storageId = device.storageIds?.firstOrNull() + if (storageId == null) { + result.error("NO_STORAGE", "No storage found on device", null) + return + } + + val objectHandles = device.getObjectHandles(storageId, 0, waypointHandle) + if (objectHandles == null) { + result.success(emptyList>()) + return + } + + val missions = mutableListOf>() + for (handle in objectHandles) { + val info = device.getObjectInfo(handle) ?: continue + if (info.format == MtpConstants.FORMAT_ASSOCIATION) { + missions.add(mapOf( + "uuid" to (info.name ?: ""), + "handle" to handle, + "dateModified" to (info.dateModified * 1000L) + )) + } + } + missions.sortByDescending { it["dateModified"] as Long } + + result.success(missions) + } catch (e: Exception) { + Log.e(TAG, "Failed to list missions", e) + result.error("MTP_ERROR", "Failed to list missions: ${e.message}", null) + } + } + + private fun transferKmz(uuid: String, kmzData: ByteArray, result: MethodChannel.Result) { + val device = currentMtpDevice + if (device == null) { + result.error("NOT_CONNECTED", "No MTP device connected", null) + return + } + try { + val waypointHandle = navigateToWaypointDir(device) + if (waypointHandle == null) { + result.error("DIR_NOT_FOUND", "DJI waypoint directory not found on device", null) + return + } + + val storageId = device.storageIds?.firstOrNull() + if (storageId == null) { + result.error("NO_STORAGE", "No storage found on device", null) + return + } + + // Find the UUID subdirectory + val uuidHandle = findChildByName(device, storageId, waypointHandle, uuid) + if (uuidHandle == null) { + result.error("MISSION_NOT_FOUND", "Mission directory '$uuid' not found", null) + return + } + + // Delete existing KMZ file if present + val existingKmzHandle = findChildByName(device, storageId, uuidHandle, "$uuid.kmz") + if (existingKmzHandle != null) { + val deleted = device.deleteObject(existingKmzHandle) + if (!deleted) { + Log.w(TAG, "Failed to delete existing KMZ, attempting overwrite") + } + } + + // Create and send new KMZ + val objectInfo = MtpObjectInfo.Builder() + .setStorageId(storageId) + .setParent(uuidHandle) + .setFormat(MtpConstants.FORMAT_UNDEFINED) + .setName("$uuid.kmz") + .setCompressedSize(kmzData.size.toLong()) + .build() + + val newHandle = device.sendObjectInfo(objectInfo) + if (newHandle == null) { + result.error("SEND_INFO_FAILED", "Failed to send object info to device", null) + return + } + + // MtpDevice.sendObject requires a ParcelFileDescriptor, so write to a temp file + val tempFile = File(activity.cacheDir, "$uuid.kmz") + try { + tempFile.writeBytes(kmzData) + val pfd = ParcelFileDescriptor.open(tempFile, ParcelFileDescriptor.MODE_READ_ONLY) + val sent = pfd.use { + device.sendObject( + newHandle.objectHandle, + kmzData.size.toLong(), + it + ) + } + + if (sent) { + sendEvent("transfer_complete", mapOf("uuid" to uuid)) + result.success(true) + } else { + result.error("SEND_FAILED", "Failed to send KMZ data to device", null) + } + } finally { + tempFile.delete() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to transfer KMZ", e) + result.error("MTP_ERROR", "Transfer failed: ${e.message}", null) + } + } + + private fun getDeviceStatus(result: MethodChannel.Result) { + result.success(mapOf( + "isConnected" to (currentMtpDevice != null) + )) + } + + /** + * Navigate the MTP object tree to find the DJI waypoint directory: + * Android/data/dji.go.v5/files/waypoint + */ + private fun navigateToWaypointDir(device: MtpDevice): Int? { + val storageId = device.storageIds?.firstOrNull() ?: return null + var currentParent = 0xFFFFFFFF.toInt() // MTP root handle + + for (segment in DJI_PATH_SEGMENTS) { + val handle = findChildByName(device, storageId, currentParent, segment) + if (handle == null) { + Log.d(TAG, "Path segment '$segment' not found in MTP tree") + return null + } + currentParent = handle + } + return currentParent + } + + /** + * Find a child object by name within a parent directory on the MTP device. + */ + private fun findChildByName(device: MtpDevice, storageId: Int, parentHandle: Int, name: String): Int? { + val handles = device.getObjectHandles(storageId, 0, parentHandle) ?: return null + for (handle in handles) { + val info = device.getObjectInfo(handle) + if (info?.name == name) { + return handle + } + } + return null + } + + private fun isMtpDevice(device: UsbDevice): Boolean { + for (i in 0 until device.interfaceCount) { + val iface = device.getInterface(i) + // USB class 6 = Still Image / MTP + if (iface.interfaceClass == 6) return true + } + return false + } + + private fun findDevice(usbManager: UsbManager, deviceName: String?): UsbDevice? { + val devices = usbManager.deviceList.values + if (deviceName != null) { + return devices.firstOrNull { it.productName == deviceName || it.deviceName == deviceName } + } + // Default: find first DJI device, or first MTP device + return devices.firstOrNull { it.vendorId == DJI_VENDOR_ID } + ?: devices.firstOrNull { isMtpDevice(it) } + } + + private fun closeMtpDevice() { + try { + currentMtpDevice?.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing MTP device", e) + } + currentMtpDevice = null + } + + private fun sendEvent(type: String, data: Map?) { + activity.runOnUiThread { + val event = mutableMapOf("type" to type) + if (data != null) event.putAll(data) + eventSink?.success(event) + } + } +} diff --git a/src/transfer-util/android/app/src/main/res/drawable-v21/launch_background.xml b/src/transfer-util/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/src/transfer-util/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/transfer-util/android/app/src/main/res/drawable/launch_background.xml b/src/transfer-util/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/src/transfer-util/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/transfer-util/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src/transfer-util/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/src/transfer-util/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src/transfer-util/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/src/transfer-util/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src/transfer-util/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/src/transfer-util/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src/transfer-util/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/src/transfer-util/android/app/src/main/res/values-night/styles.xml b/src/transfer-util/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/src/transfer-util/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/transfer-util/android/app/src/main/res/values/styles.xml b/src/transfer-util/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/src/transfer-util/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/transfer-util/android/app/src/main/res/xml/device_filter.xml b/src/transfer-util/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 00000000..d85af15f --- /dev/null +++ b/src/transfer-util/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/transfer-util/android/app/src/profile/AndroidManifest.xml b/src/transfer-util/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/src/transfer-util/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/src/transfer-util/android/build.gradle.kts b/src/transfer-util/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/src/transfer-util/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/src/transfer-util/android/gradle.properties b/src/transfer-util/android/gradle.properties new file mode 100644 index 00000000..fbee1d8c --- /dev/null +++ b/src/transfer-util/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/src/transfer-util/android/gradle/wrapper/gradle-wrapper.properties b/src/transfer-util/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e4ef43fb --- /dev/null +++ b/src/transfer-util/android/gradle/wrapper/gradle-wrapper.properties @@ -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-8.14-all.zip diff --git a/src/transfer-util/android/settings.gradle.kts b/src/transfer-util/android/settings.gradle.kts new file mode 100644 index 00000000..ca7fe065 --- /dev/null +++ b/src/transfer-util/android/settings.gradle.kts @@ -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 "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/src/transfer-util/build.sh b/src/transfer-util/build.sh new file mode 100755 index 00000000..0d3d0dbd --- /dev/null +++ b/src/transfer-util/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE="gmeligio/flutter-android:3.41.6" +OUTPUT_APK="$SCRIPT_DIR/dronetm-transfer.apk" + +echo "Building DroneTM Transfer APK using $IMAGE..." + +docker run --rm \ + -v "$SCRIPT_DIR":/app \ + -w /app \ + "$IMAGE" \ + bash -c "flutter pub get && flutter build apk --release" + +# Copy the release APK to the project root +cp "$SCRIPT_DIR/build/app/outputs/flutter-apk/app-release.apk" "$OUTPUT_APK" + +echo "Build complete: $OUTPUT_APK" diff --git a/src/transfer-util/plan.md b/src/transfer-util/plan.md new file mode 100644 index 00000000..f24f1b76 --- /dev/null +++ b/src/transfer-util/plan.md @@ -0,0 +1,360 @@ +# KMZ Transfer Utility Plan + +## Context + +Users generate drone waypoint missions (KMZ) from DroneTM web app or QField plugin, but **cannot reliably get them onto the DJI RC2 controller**. The current `dronetm-mobile` app uses SAF (Storage Access Framework) which is inherently fragile for MTP-connected devices -- it has null pointer bugs, loses permissions mid-transfer, and requires users to navigate a confusing directory picker. The manual MTP file manager workaround works (proving the hardware link is fine) but is a 12-step nightmare. + +**Constraints:** + +- DJI RC2 controller is locked down -- cannot sideload apps +- Phone ↔ Controller connected via USB cable (no WiFi/Bluetooth between them) +- Must work offline (rural Africa) +- Must work across many phone models (current app fails on various phones) + +## Root Cause Analysis + +The exploration of `dronetm-mobile` revealed why it's flaky: + +1. Uses SAF (ContentResolver + DocumentFile) which is an abstraction over MTP -- fragile for USB devices +2. Has null-pointer crash bugs (e.g. `destFile!!.exists()` at `FileTransferHandler.kt:264`) +3. Doesn't handle permission loss during transfer +4. Relies on unreliable mount-point detection (`UsbDeviceUriResolver.kt`) +5. Detects MTP devices (`isMtpDevice()`) but never actually uses the detection +6. **Never uses Android's `android.mtp.MtpDevice` API** which is what working MTP file managers use + +## Architecture: Multi-Strategy Transfer App + +A **Flutter app on the phone** that supports multiple transfer strategies, trying the most reliable first and falling back gracefully. The app handles the full workflow: receive file (from QField, browser, file picker) → connect to controller → transfer to DJI waypoint directory. + +### Transfer Strategies (in priority order) + +``` +┌────────────────────────────────────────────────────────┐ +│ DroneTM Transfer App │ +│ (on phone) │ +│ │ +│ Input: QField intent / deeplink / file picker │ +│ │ +│ Strategy 1: Direct MTP (android.mtp.MtpDevice API) │ +│ → Programmatic navigation, no picker needed │ +│ → Same approach as working MTP file managers │ +│ │ +│ Strategy 2: SAF (improved, persistent permissions) │ +│ → User navigates directory ONCE, permission cached │ +│ → Fallback when MTP API unavailable │ +│ │ +│ Strategy 3: HTTP over USB tethering │ +│ → For future controller-side app, or other devices │ +│ → Phone enables tethering, POSTs to controller IP │ +│ │ +│ Strategy 4: HTTP over WiFi/network │ +│ → For any networked setup (hotspot, shared WiFi) │ +│ → Same HTTP endpoint, different transport │ +│ │ +│ Output: KMZ written to controller at │ +│ /Android/data/dji.go.v5/files/waypoint/{uuid}/{uuid}.kmz │ +└────────────────────────────────────────────────────────┘ +``` + +### Strategy 1: Direct MTP via `android.mtp.MtpDevice` API (PRIMARY) + +This is the key improvement. Android provides `android.mtp.MtpDevice` for direct MTP protocol communication -- the same API that working MTP file manager apps use. Unlike SAF, it: + +- Communicates directly with the USB device at the protocol level +- Navigates the filesystem programmatically (no directory picker) +- Doesn't depend on ContentResolver permission scoping +- Can retry at the protocol level on transient failures + +**How it works:** + +1. Detect DJI RC2 via `UsbManager` (vendor 11427, product 4129 from `device_filter.xml`) +2. Request USB permission via `UsbManager.requestPermission()` +3. Open as `MtpDevice(usbDevice)` +4. Get storage IDs via `getStorageIds()` +5. Navigate object tree: root → `Android` → `data` → `dji.go.v5` → `files` → `waypoint` → `{uuid}/` +6. Find existing KMZ, delete it via `deleteObject()` +7. Send new KMZ via `sendObjectInfo()` + `sendObject()` +8. Close device + +**Implementation:** Kotlin platform channel in Flutter wrapping `android.mtp.MtpDevice`. ~200 lines of Kotlin + ~50 lines Dart. + +**Risk:** `MtpDevice.sendObject()` has quirks on some Android versions. Mitigation: fall back to Strategy 2. + +### Strategy 2: Improved SAF (FALLBACK) + +Fix all the bugs in the current SAF approach and make it a reliable fallback: + +1. **Persist URI permissions** -- user navigates to DJI directory ONCE, URI saved permanently via `takePersistableUriPermission()`. All future transfers skip the picker. +2. **Fix null-safety crashes** -- replace `destFile!!.exists()` with safe calls +3. **Pre-navigate picker** -- build initial URI pointing to controller's root, not phone storage +4. **Timeout handling** -- detect stalled transfers and retry +5. **Clear step-by-step guide** -- show the user exactly which folders to tap (with screenshots/names) + +### Strategy 3: HTTP over USB Tethering (FUTURE/ALTERNATIVE) + +If the controller ever allows app installation, or for other controller types: + +1. Phone enables USB tethering (one toggle) +2. Creates TCP/IP network over USB cable +3. Controller app runs HTTP server on port 8741 +4. Phone POSTs KMZ to controller IP + +Also useful for **non-DJI controllers** or **custom Android controllers** where apps CAN be sideloaded. + +### Strategy 4: HTTP over WiFi/Network + +For setups where devices share a network (hotspot, shared WiFi, etc.): + +- Same HTTP protocol as Strategy 3 +- Works with any networked receiver +- Also enables the DroneTM web app to send directly + +## App Input Methods + +The app accepts KMZ files from multiple sources: + +| Source | Mechanism | Notes | +| ---------------------- | ----------------------------------------------- | --------------------------------------------------- | +| **QField plugin** | Android Share intent | QField shares file → our app appears in share sheet | +| **QField plugin** | Custom deeplink `dronetm://transfer?file={uri}` | Direct trigger from plugin | +| **DroneTM web app** | Download + share | User downloads KMZ, shares to our app | +| **File manager** | Share intent / file open | Any `.kmz` file → our app | +| **In-app file picker** | Manual selection | User browses for downloaded KMZ | + +## Project Structure + +``` +src/transfer-util/ +├── lib/ +│ ├── main.dart # Entry point, strategy selection +│ ├── models/ +│ │ └── waypoint_mission.dart # UUID + metadata +│ ├── services/ +│ │ ├── transfer_strategy.dart # Abstract strategy interface +│ │ ├── mtp_transfer.dart # Strategy 1: MtpDevice wrapper (calls platform channel) +│ │ ├── saf_transfer.dart # Strategy 2: SAF with persistent permissions +│ │ ├── http_transfer.dart # Strategy 3+4: HTTP client for network transfers +│ │ └── connection_detector.dart # Detect USB device, tethering, WiFi +│ ├── screens/ +│ │ ├── home_screen.dart # Main UI: status, file selection, transfer +│ │ ├── strategy_screen.dart # Manual strategy selection if auto-detect fails +│ │ └── setup_screen.dart # First-time: permissions, instructions +│ └── platform/ +│ └── mtp_channel.dart # Dart side of platform channel +├── android/ +│ └── app/src/main/ +│ ├── AndroidManifest.xml # USB host, storage permissions, intent filters +│ ├── kotlin/.../ +│ │ ├── MtpTransferPlugin.kt # Platform channel: MtpDevice API wrapper +│ │ └── UsbDeviceDetector.kt # USB device detection + filtering +│ └── res/xml/ +│ └── device_filter.xml # DJI RC2 USB identifiers +├── pubspec.yaml +└── test/ +``` + +## Android Manifest + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Platform Channel: MTP Transfer (Kotlin) + +The core native code wrapping `android.mtp.MtpDevice`: + +```kotlin +// MtpTransferPlugin.kt - Key operations +class MtpTransferPlugin : FlutterPlugin, MethodCallHandler { + + fun listMissions(device: MtpDevice): List { + // Navigate: root → Android → data → dji.go.v5 → files → waypoint + // List UUID subdirectories + // Return [{uuid, lastModified}] + } + + fun transferKmz(device: MtpDevice, uuid: String, kmzBytes: ByteArray): Boolean { + // Navigate to waypoint/{uuid}/ + // Delete existing {uuid}.kmz if present + // sendObjectInfo() + sendObject() with new KMZ + // Return success/failure + } +} +``` + +## QField Plugin Integration + +Add to `src/qfield-plugin/main.qml` (~40 lines): + +```javascript +function sendViaTransferApp(kmzData) { + // Option A: Android intent (share the KMZ file) + // Option B: If controller IP known, direct HTTP POST + Qt.openUrlExternally("dronetm://transfer?file=" + encodeURIComponent(kmzFilePath)); +} +``` + +Add to `FlightplanDialog.qml` (~20 lines): + +- "Send to Controller" button (appears after generation) +- Triggers the transfer app via intent/deeplink + +## DroneTM Web App Integration + +New `src/frontend/src/utils/wifi-transfer.ts` (~30 lines): + +```typescript +export async function sendKmzViaHttp(ip: string, port: number, blob: Blob, uuid?: string) { + const formData = new FormData(); + formData.append("file", blob, "mission.kmz"); + if (uuid) formData.append("uuid", uuid); + const res = await fetch(`http://${ip}:${port}/upload`, { method: "POST", body: formData }); + if (!res.ok) throw new Error(`Transfer failed: ${res.statusText}`); +} +``` + +## "Must Have Prior Mission" Constraint + +DJI stores mission records in a sandboxed database. Can only REPLACE existing KMZ files: + +1. App scans for existing UUID directories +2. Shows list of available mission slots (sorted by recency) +3. Auto-selects most recent mission +4. Clear first-time message: "Create one test waypoint mission in DJI Fly first" + +## Implementation Phases + +### Phase 1: MTP Transfer Core + +- Flutter project scaffolding at `src/transfer-util/` +- Kotlin platform channel wrapping `android.mtp.MtpDevice` +- USB device detection for DJI RC2 +- Mission listing (scan UUID directories) +- KMZ write (delete + send) +- Basic UI: device status, mission list, file picker, transfer button + +### Phase 2: Input Handlers + +- Android Share intent receiver (`.kmz` files) +- Custom deeplink handler (`dronetm://transfer`) +- In-app file picker for manual selection + +### Phase 3: SAF Fallback + +- Improved SAF with persistent URI permissions +- One-time directory navigation with clear guidance +- Auto-fallback when MTP strategy fails + +### Phase 4: QField + Web Integration + +- QField plugin: "Send to Controller" button + deeplink trigger +- DroneTM web: HTTP transfer option alongside WebUSB/ADB + +### Phase 5: Network Strategies + +- HTTP transfer client (for USB tethering and WiFi scenarios) +- Connection detection (USB tethering, WiFi, etc.) +- Auto-strategy selection based on available connections + +## Replaces `dronetm-mobile` + +The existing `dronetm-mobile/` Kotlin app is deprecated entirely. The new app addresses every failure mode: + +- **Crash on null pointer** → Safe Kotlin + Dart null handling +- **SAF directory navigation confusion** → MTP API navigates programmatically +- **Permission loss mid-transfer** → MTP keeps USB connection open +- **Unreliable mount point detection** → Direct USB device API +- **No fallback** → Multiple strategies with graceful degradation + +## Risks + +| Risk | Likelihood | Mitigation | +| ---------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `MtpDevice.sendObject()` quirky on some Android versions | Medium | Fall back to SAF strategy; test on multiple phones | +| Some phones don't support USB Host / MTP Host mode | Low | SAF fallback still works; most modern phones support USB Host | +| MTP write access to `/Android/data/` blocked on controller | Medium | Manual MTP managers can do it (proven), so API should too. If blocked, SAF fallback. | +| QML deeplink/intent support limited | Low | QField can share files via standard Android share; intent is the backup | +| Flutter MTP platform channel complexity | Medium | Well-documented Android API; ~200 lines of Kotlin | + +## Verification + +1. Build and install on test phone with DJI RC2 connected via USB +2. Verify MTP strategy: auto-detect controller → list missions → transfer KMZ → confirm in DJI Fly +3. Verify SAF fallback: disable MTP → SAF picker opens → persist permission → transfer works on retry +4. Verify share intent: share KMZ from file manager → app opens → transfer succeeds +5. Verify QField deeplink: generate flightplan in QField → "Send to Controller" → app opens → transfer +6. Test on 3+ phone models to verify cross-device reliability +7. Test with no internet (airplane mode) to verify offline operation + +--- + +Phase 1 Complete: Transfer Utility Scaffolding + +src/transfer-util/ -- Flutter app with MTP transfer core (~1,200 lines across 7 source files) + +Files created: + +Android native (Kotlin): + +- MtpTransferPlugin.kt (386 lines) -- Core MTP platform channel using android.mtp.MtpDevice API: + - USB device detection (DJI vendor ID 11427) + - MTP device open/close + - Navigates Android/data/dji.go.v5/files/waypoint/ programmatically + - Lists mission UUID directories + - Deletes old KMZ + sends new KMZ via sendObjectInfo()/sendObject() + - Event stream for USB attach/detach/permission events +- MainActivity.kt (56 lines) -- Handles USB, share intents, and deeplinks +- device_filter.xml -- DJI USB device identifiers +- AndroidManifest.xml -- Permissions (USB host, storage), intent filters (share, deeplink dronetm://transfer, .kmz files) + +Dart: + +- mtp_channel.dart (125 lines) -- Platform channel wrapper with typed models +- transfer_service.dart (222 lines) -- State management orchestrating the full flow +- home_screen.dart (350 lines) -- Material 3 UI with device list, file picker, mission selector, transfer button +- waypoint_mission.dart (44 lines) -- Models and enums +- main.dart (34 lines) -- App entry with HOT red theme + +App ID: org.hotosm.drone_tm + +What's ready to test: + +1. Connect phone to DJI RC2 via USB +2. App auto-detects controller +3. One tap to grant USB permission +4. Scans missions automatically (no directory picker needed!) +5. Pick a KMZ file or share from QField +6. One tap to transfer + +Next phases (not yet built): + +- Phase 2: Share intent / deeplink handlers are wired in the manifest but need end-to-end testing +- Phase 3: SAF fallback strategy +- Phase 4: QField plugin integration + DroneTM web integration +- Phase 5: HTTP transfer for network scenarios diff --git a/src/transfer-util/pubspec.lock b/src/transfer-util/pubspec.lock new file mode 100644 index 00000000..ef74651a --- /dev/null +++ b/src/transfer-util/pubspec.lock @@ -0,0 +1,314 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/src/transfer-util/pubspec.yaml b/src/transfer-util/pubspec.yaml new file mode 100644 index 00000000..acb7b9a8 --- /dev/null +++ b/src/transfer-util/pubspec.yaml @@ -0,0 +1,21 @@ +name: dronetm_transfer +description: "DroneTM Transfer - Reliable KMZ waypoint mission file transfer to DJI controllers" +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +dependencies: + flutter: + sdk: flutter + provider: ^6.1.2 + file_picker: ^11.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/src/transfer-util/test/widget_test.dart b/src/transfer-util/test/widget_test.dart new file mode 100644 index 00000000..1ed7e760 --- /dev/null +++ b/src/transfer-util/test/widget_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dronetm_transfer/main.dart'; + +void main() { + testWidgets('App launches', (WidgetTester tester) async { + await tester.pumpWidget(const DroneTMTransferApp()); + expect(find.text('DroneTM Transfer'), findsOneWidget); + }); +} From b37d80abab760cd3497360f79760a245d43b928a Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Mon, 20 Apr 2026 21:08:31 +0100 Subject: [PATCH 3/3] feat: complete implementation, phases 1-->5 with 3 file transfer possibilities (fallbacks) --- src/qfield-plugin/FlightplanDialog.qml | 10 +- src/qfield-plugin/main.qml | 29 ++- .../android/app/build.gradle.kts | 4 + .../hotosm/dronetm_transfer/MainActivity.kt | 18 +- .../dronetm_transfer/MtpTransferPlugin.kt | 88 +++++++ .../dronetm_transfer/SafTransferPlugin.kt | 236 ++++++++++++++++++ src/transfer-util/plan.md | 10 + src/transfer-util/pubspec.lock | 8 +- 8 files changed, 385 insertions(+), 18 deletions(-) create mode 100644 src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/SafTransferPlugin.kt diff --git a/src/qfield-plugin/FlightplanDialog.qml b/src/qfield-plugin/FlightplanDialog.qml index f28d98c1..1e8dd0ef 100644 --- a/src/qfield-plugin/FlightplanDialog.qml +++ b/src/qfield-plugin/FlightplanDialog.qml @@ -413,11 +413,11 @@ QfDialog { Label { visible: generationState === "transfer_failed" text: qsTr("To transfer the flightplan to your DJI controller:\n\n" + - "1. Ensure the controller is connected via USB and has at least one prior waypoint mission\n" + - "2. Use a file manager app to copy the .kmz from this project's flightplans/ folder to:\n" + - " Android/data/dji.go.v5/files/waypoint//\n" + - "3. Or transfer later via the DroneTM web app (requires internet) using ADB Web transfer\n\n" + - "Tip: If this is a new controller, fly one test waypoint mission first so DJI creates the waypoint directory.") + "1. Connect the controller to your phone via USB cable\n" + + "2. Tap 'Copy to Flight Controller' below — this opens the DroneTM Transfer app\n" + + "3. In the Transfer app: grant USB permission, select the mission slot, and tap Transfer\n\n" + + "If the DroneTM Transfer app is not installed, you'll be prompted to save the file manually.\n\n" + + "Tip: If this is a new controller, fly one short waypoint mission first so DJI creates the waypoint directory.") font.pixelSize: Theme.defaultFont.pixelSize * 0.8 wrapMode: Text.WordWrap Layout.fillWidth: true diff --git a/src/qfield-plugin/main.qml b/src/qfield-plugin/main.qml index 344c8b5e..647a08dd 100644 --- a/src/qfield-plugin/main.qml +++ b/src/qfield-plugin/main.qml @@ -577,15 +577,27 @@ Item { log("Attempting to export KMZ to flight controller...") - // First try the direct-write path (works when QField runs on the DJI RC - // itself or the controller is mounted with relaxed scoped-storage rules). + // Strategy 1: Open DroneTM Transfer app via deeplink. + // The transfer app handles MTP/SAF to write onto the DJI controller. + try { + var fileUri = "file://" + lastKmzPath + var deeplink = "dronetm://transfer?file=" + encodeURIComponent(fileUri) + log("Trying DroneTM Transfer deeplink: " + deeplink) + Qt.openUrlExternally(deeplink) + mainWindow.displayToast(qsTr('Opening DroneTM Transfer...')) + flightplanDialog.resultMessage = qsTr('Sent to DroneTM Transfer app. Follow the steps there to complete the transfer.') + return + } catch (e) { + log("DroneTM Transfer deeplink failed: " + e) + } + + // Strategy 2: Direct filesystem write (works when QField runs on the DJI + // RC itself or the controller is mounted with relaxed scoped-storage). if (lastKmzData && _tryDirectCopyToController()) { return } - // Fall back to the platform file-picker so the user can choose where to - // save. platformUtilities.exportDatasetTo() shows Android's SAF folder - // picker and copies the file for us (not yet available on iOS). + // Strategy 3: Platform file-picker (Android SAF folder picker). try { if (typeof platformUtilities !== 'undefined' && platformUtilities.exportDatasetTo) { log("Opening file picker via platformUtilities.exportDatasetTo") @@ -599,14 +611,15 @@ Item { log("platformUtilities.exportDatasetTo failed: " + e) } - // Neither method worked - log("Could not export KMZ via direct copy or file picker") + // No method worked + log("Could not export KMZ via any method") mainWindow.displayToast( qsTr('DJI controller not found - see transfer options below') ) flightplanDialog.generationState = "transfer_failed" flightplanDialog.resultMessage = qsTr( - 'Could not find DJI controller storage. The KMZ is saved in the project flightplans/ folder.' + 'Could not find DJI controller storage. The KMZ is saved in the project flightplans/ folder.\n\n' + + 'Install the DroneTM Transfer app for reliable USB transfer.' ) } diff --git a/src/transfer-util/android/app/build.gradle.kts b/src/transfer-util/android/app/build.gradle.kts index 4a06368a..fe5a0951 100644 --- a/src/transfer-util/android/app/build.gradle.kts +++ b/src/transfer-util/android/app/build.gradle.kts @@ -36,6 +36,10 @@ android { } } +dependencies { + implementation("androidx.documentfile:documentfile:1.0.1") +} + flutter { source = "../.." } diff --git a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt index 22ec638e..ba9a7541 100644 --- a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt +++ b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MainActivity.kt @@ -2,6 +2,7 @@ package org.hotosm.dronetm_transfer import android.content.Intent import android.hardware.usb.UsbManager +import android.os.Build import android.os.Bundle import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -9,11 +10,14 @@ import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { private lateinit var mtpPlugin: MtpTransferPlugin + private lateinit var safPlugin: SafTransferPlugin override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) mtpPlugin = MtpTransferPlugin() mtpPlugin.register(this, flutterEngine) + safPlugin = SafTransferPlugin() + safPlugin.register(this, flutterEngine) } override fun onNewIntent(intent: Intent) { @@ -27,13 +31,25 @@ class MainActivity : FlutterActivity() { handleIntent(intent) } + @Deprecated("Use registerForActivityResult", ReplaceWith("registerForActivityResult")) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (safPlugin.onActivityResult(requestCode, resultCode, data)) return + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) + } + private fun handleIntent(intent: Intent) { when (intent.action) { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { mtpPlugin.onUsbDeviceAttached() } Intent.ACTION_SEND -> { - val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } if (uri != null) { mtpPlugin.onFileReceived(uri.toString()) } diff --git a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt index e6385b17..60b21f21 100644 --- a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt +++ b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/MtpTransferPlugin.kt @@ -114,6 +114,11 @@ class MtpTransferPlugin : MethodChannel.MethodCallHandler { result.success(true) } "getDeviceStatus" -> getDeviceStatus(result) + "readContentUri" -> { + val uri = call.argument("uri") ?: return result.error("INVALID_ARGS", "uri required", null) + readContentUri(uri, result) + } + "getInitialIntent" -> getInitialIntent(result) else -> result.notImplemented() } } @@ -329,6 +334,89 @@ class MtpTransferPlugin : MethodChannel.MethodCallHandler { )) } + /** + * Read bytes from a content:// URI using ContentResolver. + * Returns a map with "bytes" (ByteArray), "name" (String), and "size" (Long). + */ + private fun readContentUri(uriString: String, result: MethodChannel.Result) { + try { + val uri = android.net.Uri.parse(uriString) + val contentResolver = activity.contentResolver + + // Get display name + var displayName = "unknown.kmz" + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + displayName = cursor.getString(nameIndex) ?: displayName + } + } + } + + // Read bytes + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + result.error("READ_FAILED", "Could not read file from URI", null) + return + } + + result.success(mapOf( + "bytes" to bytes, + "name" to displayName, + "size" to bytes.size.toLong() + )) + } catch (e: Exception) { + Log.e(TAG, "Failed to read content URI", e) + result.error("READ_ERROR", "Failed to read file: ${e.message}", null) + } + } + + /** + * Return the intent that launched the activity (for cold-start share/deeplink handling). + */ + private var initialIntentHandled = false + private fun getInitialIntent(result: MethodChannel.Result) { + if (initialIntentHandled) { + result.success(null) + return + } + initialIntentHandled = true + val intent = activity.intent ?: return result.success(null) + when (intent.action) { + Intent.ACTION_SEND -> { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (uri != null) { + result.success(mapOf("action" to "share", "uri" to uri.toString())) + } else { + result.success(null) + } + } + Intent.ACTION_VIEW -> { + val uri = intent.data + if (uri != null) { + if (uri.scheme == "dronetm") { + result.success(mapOf( + "action" to "deeplink", + "uri" to (uri.getQueryParameter("file") ?: ""), + "host" to (uri.host ?: "") + )) + } else { + result.success(mapOf("action" to "view", "uri" to uri.toString())) + } + } else { + result.success(null) + } + } + else -> result.success(null) + } + } + /** * Navigate the MTP object tree to find the DJI waypoint directory: * Android/data/dji.go.v5/files/waypoint diff --git a/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/SafTransferPlugin.kt b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/SafTransferPlugin.kt new file mode 100644 index 00000000..11a9bb05 --- /dev/null +++ b/src/transfer-util/android/app/src/main/kotlin/org/hotosm/dronetm_transfer/SafTransferPlugin.kt @@ -0,0 +1,236 @@ +package org.hotosm.dronetm_transfer + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * SAF (Storage Access Framework) fallback for when MTP direct API fails. + * + * Key improvements over dronetm-mobile: + * - Persists URI permissions so user navigates the directory ONCE + * - Null-safe throughout (no !! operators) + * - Clear error messages at every step + * - Pre-navigates the picker to the USB device root when possible + */ +class SafTransferPlugin : MethodChannel.MethodCallHandler { + + companion object { + private const val TAG = "SafTransfer" + private const val METHOD_CHANNEL = "org.hotosm.drone_tm/saf" + private const val PREFS_NAME = "saf_prefs" + private const val PREF_WAYPOINT_URI = "waypoint_dir_uri" + private const val REQUEST_OPEN_TREE = 42 + } + + private lateinit var activity: Activity + private lateinit var methodChannel: MethodChannel + private var pendingResult: MethodChannel.Result? = null + + fun register(activity: Activity, flutterEngine: FlutterEngine) { + this.activity = activity + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "hasPersistedUri" -> hasPersistedUri(result) + "openDirectoryPicker" -> openDirectoryPicker(result) + "listMissions" -> listMissionsSaf(result) + "transferKmz" -> { + val uuid = call.argument("uuid") + ?: return result.error("INVALID_ARGS", "uuid required", null) + val kmzData = call.argument("kmzData") + ?: return result.error("INVALID_ARGS", "kmzData required", null) + transferKmzSaf(uuid, kmzData, result) + } + "clearPersistedUri" -> { + prefs.edit().remove(PREF_WAYPOINT_URI).apply() + result.success(true) + } + else -> result.notImplemented() + } + } + + private val prefs: SharedPreferences + get() = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Check if we have a persisted URI for the waypoint directory. + */ + private fun hasPersistedUri(result: MethodChannel.Result) { + val uriString = prefs.getString(PREF_WAYPOINT_URI, null) + if (uriString == null) { + result.success(false) + return + } + // Verify the permission is still valid + val uri = Uri.parse(uriString) + val persistedUris = activity.contentResolver.persistedUriPermissions + val stillValid = persistedUris.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + if (!stillValid) { + prefs.edit().remove(PREF_WAYPOINT_URI).apply() + } + result.success(stillValid) + } + + /** + * Launch the SAF directory picker for the user to navigate to the + * DJI waypoint directory on the controller. + */ + private fun openDirectoryPicker(result: MethodChannel.Result) { + pendingResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + try { + activity.startActivityForResult(intent, REQUEST_OPEN_TREE) + } catch (e: Exception) { + Log.e(TAG, "Failed to open directory picker", e) + pendingResult = null + result.error("PICKER_FAILED", "Could not open directory picker: ${e.message}", null) + } + } + + /** + * Called from MainActivity when the directory picker returns. + */ + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode != REQUEST_OPEN_TREE) return false + + val result = pendingResult + pendingResult = null + + if (resultCode != Activity.RESULT_OK || data?.data == null) { + result?.success(false) + return true + } + + val treeUri = data.data!! + + // Persist the permission so the user never has to do this again + try { + activity.contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to persist URI permission", e) + result?.error("PERMISSION_FAILED", "Could not persist directory permission", null) + return true + } + + // Verify the selected directory contains waypoint missions (or is close) + val docFile = DocumentFile.fromTreeUri(activity, treeUri) + if (docFile == null || !docFile.isDirectory) { + result?.error("INVALID_DIR", "Selected path is not a directory", null) + return true + } + + // Save the URI + prefs.edit().putString(PREF_WAYPOINT_URI, treeUri.toString()).apply() + Log.i(TAG, "Persisted waypoint directory URI: $treeUri") + result?.success(true) + return true + } + + /** + * List mission UUIDs from the persisted waypoint directory via SAF. + */ + private fun listMissionsSaf(result: MethodChannel.Result) { + val waypointDir = getPersistedWaypointDir() + if (waypointDir == null) { + result.error("NO_DIR", "No waypoint directory configured. Use the directory picker first.", null) + return + } + + try { + val missions = mutableListOf>() + for (child in waypointDir.listFiles()) { + if (child.isDirectory) { + missions.add(mapOf( + "uuid" to (child.name ?: ""), + "dateModified" to child.lastModified() + )) + } + } + missions.sortByDescending { it["dateModified"] as Long } + result.success(missions) + } catch (e: Exception) { + Log.e(TAG, "Failed to list missions via SAF", e) + result.error("SAF_ERROR", "Failed to list missions: ${e.message}", null) + } + } + + /** + * Transfer a KMZ file to a mission slot via SAF. + */ + private fun transferKmzSaf(uuid: String, kmzData: ByteArray, result: MethodChannel.Result) { + val waypointDir = getPersistedWaypointDir() + if (waypointDir == null) { + result.error("NO_DIR", "No waypoint directory configured.", null) + return + } + + try { + // Find the UUID subdirectory + val uuidDir = waypointDir.listFiles().firstOrNull { it.name == uuid && it.isDirectory } + if (uuidDir == null) { + result.error("MISSION_NOT_FOUND", "Mission directory '$uuid' not found", null) + return + } + + // Delete existing KMZ if present + val existingKmz = uuidDir.listFiles().firstOrNull { it.name == "$uuid.kmz" } + if (existingKmz != null) { + val deleted = existingKmz.delete() + if (!deleted) { + Log.w(TAG, "Failed to delete existing KMZ via SAF") + } + } + + // Create new file + val newFile = uuidDir.createFile("application/vnd.google-earth.kmz", "$uuid.kmz") + if (newFile == null) { + result.error("CREATE_FAILED", "Failed to create file in mission directory", null) + return + } + + // Write data + val outputStream = activity.contentResolver.openOutputStream(newFile.uri) + if (outputStream == null) { + result.error("WRITE_FAILED", "Could not open file for writing", null) + return + } + + outputStream.use { it.write(kmzData) } + + Log.i(TAG, "SAF transfer complete: $uuid.kmz (${kmzData.size} bytes)") + result.success(true) + } catch (e: Exception) { + Log.e(TAG, "SAF transfer failed", e) + result.error("SAF_ERROR", "Transfer failed: ${e.message}", null) + } + } + + private fun getPersistedWaypointDir(): DocumentFile? { + val uriString = prefs.getString(PREF_WAYPOINT_URI, null) ?: return null + val uri = Uri.parse(uriString) + return DocumentFile.fromTreeUri(activity, uri) + } +} diff --git a/src/transfer-util/plan.md b/src/transfer-util/plan.md index f24f1b76..f319b926 100644 --- a/src/transfer-util/plan.md +++ b/src/transfer-util/plan.md @@ -358,3 +358,13 @@ Next phases (not yet built): - Phase 3: SAF fallback strategy - Phase 4: QField plugin integration + DroneTM web integration - Phase 5: HTTP transfer for network scenarios + +--- + +How the fallback works: + +1. App tries MTP first (primary strategy) +2. If MTP fails at any step, it switches to SAF mode with a clear message +3. In SAF mode, the user picks the waypoint directory ONCE -- permission is persisted in SharedPreferences +4. Subsequent transfers skip the picker entirely +5. User can manually switch between modes via the strategy indicator bar diff --git a/src/transfer-util/pubspec.lock b/src/transfer-util/pubspec.lock index ef74651a..ccf62075 100644 --- a/src/transfer-util/pubspec.lock +++ b/src/transfer-util/pubspec.lock @@ -156,10 +156,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -265,10 +265,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.8" vector_math: dependency: transitive description: