Give the Fly App GO 4-style ActiveTrack and TapFly

P7b of the GO 4-parity plan. Both modes open from the MODES picker on the
live video, where they take over the touches the camera and gimbal used.

ActiveTrack: drag a box round the subject (or tap it), then GO. The
operator's own box follows on the video, coloured by confidence, with
confirm / reject when it asks and the reason when it cannot follow.
Trace, Profile and Spotlight switch live from the mode panel.

TapFly: tap where to fly, then GO; the aircraft's heading point and any
obstacle bypass show while it flies, and the speed changes live. Forward
only on aircraft without rear sensors; Backward and Free otherwise.

Both go through new SafetyGate checks (flying, P mode, above 2 m, nothing
else in control, battery), start on GO when the check is clean and ask for
the slide when it warns. Stop on the rail and Exit stop them. MissionBridge
now listens to the ActiveTrack and TapFly operators. Not yet run on a
simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 17:11:09 +02:00
1 parent 43a66df8df
commit 2c908cf17e
13 files changed
+1519 -12

No files matched your search

@@ -1,10 +1,14 @@
package com.pilotvault.flyapp
import android.graphics.PointF
import android.graphics.RectF
import dji.common.error.DJIError
import dji.common.mission.activetrack.ActiveTrackMission
import dji.common.mission.activetrack.ActiveTrackMissionEvent
import dji.common.mission.activetrack.ActiveTrackMode
import dji.common.mission.activetrack.QuickShotMode
import dji.common.mission.tapfly.TapFlyMission
import dji.common.mission.tapfly.TapFlyMode
import dji.common.mission.waypoint.Waypoint
import dji.common.mission.waypoint.WaypointAction
import dji.common.mission.waypoint.WaypointActionType
@@ -19,7 +23,11 @@ import dji.common.mission.waypoint.WaypointMissionUploadEvent
import dji.common.mission.waypoint.WaypointTurnMode
import dji.common.model.LocationCoordinate2D
import dji.sdk.base.BaseProduct
import dji.sdk.mission.activetrack.ActiveTrackMissionOperatorListener
import dji.sdk.mission.activetrack.ActiveTrackOperator
import dji.sdk.mission.tapfly.TapFlyMissionEvent
import dji.sdk.mission.tapfly.TapFlyMissionOperator
import dji.sdk.mission.tapfly.TapFlyMissionOperatorListener
import dji.sdk.mission.waypoint.WaypointMissionOperator
import dji.sdk.mission.waypoint.WaypointMissionOperatorListener
import dji.sdk.sdkmanager.DJISDKManager
@@ -44,7 +52,18 @@ import io.flutter.plugin.common.MethodChannel
* `operator: 'waypoint'`: `state` (WaypointMissionState), `upload`
* {index, total, summary}, `execution` {target, reached, executeState,
* total}, `event` (`start` / `finish`) and `error`.
* - ActiveTrack subject tracking → [ActiveTrackOperator].
* - ActiveTrack subject tracking → [ActiveTrackOperator]. Its listener goes
* out as `mission` events with `operator: 'activeTrack'`: `state`
* (ActiveTrackState), `tracking` {targetState, targetType, rect: {l, t, r, b}
* normalised to the live view, index, percent, reason}, `error`. Methods:
* `startActiveTrack {x, y, w, h, mode}` (checked with `canStartTracking`
* first), `acceptActiveTrackConfirmation`, `rejectActiveTrackConfirmation`,
* `switchActiveTrackMode {mode}`, `setActiveTrackRetreat {on}`, `stopActiveTrack`.
* - TapFly → [TapFlyMissionOperator]: `mission` events with `operator:
* 'tapFly'`: `state` (TapFlyMissionState), `execution` {imageX, imageY,
* bypass, relativeHeading, speed}, `error`. Methods: `startTapFly {x, y,
* mode, speed, avoidance}` (x / y normalised), `stopTapFly`,
* `setTapFlySpeed {speed}`, `resetTapFlyHeading`.
* - DJI-Fly QuickShots (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid) → an
* ActiveTrack mission in [ActiveTrackMode.QUICK_SHOT] with the matching
* [QuickShotMode]. Unsupported modes on a given aircraft fail cleanly.
@@ -57,7 +76,49 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
private fun trackOp(): ActiveTrackOperator? =
DJISDKManager.getInstance().missionControl?.activeTrackOperator
private fun tapFlyOp(): TapFlyMissionOperator? =
DJISDKManager.getInstance().missionControl?.tapFlyMissionOperator
private var listenedOp: WaypointMissionOperator? = null
private var listenedTrack: ActiveTrackOperator? = null
private var listenedTapFly: TapFlyMissionOperator? = null
private fun rect(r: RectF?): Map<String, Any?>? =
r?.let { mapOf("l" to it.left.toDouble(), "t" to it.top.toDouble(), "r" to it.right.toDouble(), "b" to it.bottom.toDouble()) }
private val trackListener = ActiveTrackMissionOperatorListener { event: ActiveTrackMissionEvent ->
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "activeTrack")
event.currentState?.let { m["state"] = it.name }
event.trackingState?.let { t ->
m["tracking"] = mapOf(
"targetState" to t.state?.name,
"targetType" to t.type?.name,
"rect" to rect(t.targetRect),
"index" to t.targetIndex,
"percent" to t.completionPercentage,
"reason" to t.reason?.name,
)
}
event.error?.let { m["error"] = it.description }
ctx.emit(m)
}
private val tapFlyListener = TapFlyMissionOperatorListener { event: TapFlyMissionEvent? ->
if (event == null) return@TapFlyMissionOperatorListener
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "tapFly")
event.currentState?.let { m["state"] = it.name }
event.executionState?.let { x ->
m["execution"] = mapOf(
"imageX" to x.imageLocation?.x?.toDouble(),
"imageY" to x.imageLocation?.y?.toDouble(),
"bypass" to x.bypassDirection?.name,
"relativeHeading" to x.relativeHeading.toDouble(),
"speed" to x.speed.toDouble(),
)
}
event.error?.let { m["error"] = it.description }
ctx.emit(m)
}
private val waypointListener = object : WaypointMissionOperatorListener {
override fun onDownloadUpdate(event: WaypointMissionDownloadEvent) {}
@@ -113,6 +174,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
}
override fun bind(product: BaseProduct?) {
trackOp()?.let { op ->
if (listenedTrack !== op) {
listenedTrack?.removeListener(trackListener)
op.addListener(trackListener)
listenedTrack = op
}
ctx.emit(mapOf("type" to "mission", "operator" to "activeTrack", "state" to op.currentState?.name))
}
tapFlyOp()?.let { op ->
if (listenedTapFly !== op) {
listenedTapFly?.removeListener(tapFlyListener)
op.addListener(tapFlyListener)
listenedTapFly = op
}
}
val op = waypointOp() ?: return
if (listenedOp !== op) {
listenedOp?.removeListener(waypointListener)
@@ -141,7 +217,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
}
"startActiveTrack" -> startActiveTrack(call, result)
"stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) }
"acceptActiveTrackConfirmation" -> track(result) { it.acceptConfirmation(ctx.completion(result)) }
"rejectActiveTrackConfirmation" -> track(result) { it.rejectConfirmation(ctx.completion(result)) }
"switchActiveTrackMode" -> track(result) {
it.switchMode(enumArg(call, "mode", ActiveTrackMode.TRACE), ctx.completion(result))
}
"setActiveTrackRetreat" -> track(result) {
it.setRetreatEnabled(call.argument<Boolean>("on") ?: false, ctx.completion(result))
}
"startQuickShot" -> startQuickShot(call, result)
"startTapFly" -> startTapFly(call, result)
"stopTapFly" -> tapFly(result) { it.stopMission(ctx.completion(result)) }
"setTapFlySpeed" -> tapFly(result) {
it.setAutoFlightSpeed((call.argument<Number>("speed") ?: 5).toFloat(), ctx.completion(result))
}
"resetTapFlyHeading" -> tapFly(result) { it.resetHeading(ctx.completion(result)) }
else -> return false
}
return true
@@ -157,6 +247,24 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
block(op)
}
private inline fun tapFly(result: MethodChannel.Result, block: (TapFlyMissionOperator) -> Unit) {
val op = tapFlyOp() ?: run { ctx.fail(result, "TapFly unavailable"); return }
block(op)
}
private fun startTapFly(call: MethodCall, result: MethodChannel.Result) {
val op = tapFlyOp() ?: run { ctx.fail(result, "TapFly unavailable"); return }
val mission = TapFlyMission()
mission.tapFlyMode = enumArg(call, "mode", TapFlyMode.FORWARD)
mission.speed = (call.argument<Number>("speed") ?: 5).toFloat()
mission.isHorizontalObstacleAvoidanceEnabled = call.argument<Boolean>("avoidance") ?: true
mission.target = PointF(
(call.argument<Number>("x") ?: 0.5).toFloat(),
(call.argument<Number>("y") ?: 0.5).toFloat(),
)
op.startMission(mission, ctx.completion(result))
}
private inline fun <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
val raw = call.argument<String>(name) ?: return fallback
return try {
@@ -244,7 +352,11 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
ActiveTrackMode.TRACE
}
val mission = ActiveTrackMission(RectF(x, y, x + w, y + h), mode)
ctx.emit(mapOf("type" to "mission", "state" to "tracking"))
op.canStartTracking(mission)?.let {
ctx.fail(result, it.description)
return
}
// The operator's listener reports the tracking from here on.
op.startTracking(mission, ctx.completion(result))
}