diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MainActivity.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MainActivity.kt index facc6d9..9fec15a 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MainActivity.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MainActivity.kt @@ -4,10 +4,12 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.view.WindowManager import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel // FlutterFragmentActivity (not FlutterActivity) is required by local_auth so the // platform BiometricPrompt can attach to a FragmentActivity host. @@ -21,6 +23,16 @@ class MainActivity : FlutterFragmentActivity() { // Live DJI video feed rendered behind the Flight Control HUD. flutterEngine.platformViewsController.registry .registerViewFactory(DjiVideoView.VIEW_TYPE, DjiVideoViewFactory()) + // The flight screen keeps the display on (see ScreenAwake in Dart). + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "pilotvault/screen").setMethodCallHandler { call, result -> + if (call.method == "keepOn") { + if (call.arguments == true) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + result.success(null) + } else { + result.notImplemented() + } + } } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index 65f24c1..5854cb4 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -936,6 +936,8 @@ class SafetyGate { if (!model.connected) const GateCheck.block('No aircraft connected.'), if (model.connected && on && RailState.activeMode(_a, model.mission) != ActiveMode.none) const GateCheck.block('Stop the intelligent flight mode that is running first.'), + // A Mavic Pro accepts the write on the ground and keeps Tripod off. + if (model.connected && on && !cinematic && _a.isFlying != true) const GateCheck.block('Take off first: Tripod works in flight.'), if (model.connected && on) GateCheck.warn(cinematic ? 'The aircraft brakes and turns slowly: give it room to stop.' diff --git a/Fly App/lib/fly/screen_awake.dart b/Fly App/lib/fly/screen_awake.dart new file mode 100644 index 0000000..55f12d9 --- /dev/null +++ b/Fly App/lib/fly/screen_awake.dart @@ -0,0 +1,14 @@ +import 'package:flutter/services.dart'; + +/// Keeps the tablet's screen on while the flight screen is open: Android's +/// sleep timeout (2 min on the dev tablet) otherwise blanks the HUD mid-flight. +abstract final class ScreenAwake { + static const MethodChannel _channel = MethodChannel('pilotvault/screen'); + + /// Best-effort: a missing host (widget tests) just leaves the screen alone. + static Future keepOn(bool on) async { + try { + await _channel.invokeMethod('keepOn', on); + } catch (_) {} + } +} diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index cb68a5a..168bba4 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart' show LatLng; import '../../dji_service.dart'; import '../../flight_model.dart'; +import '../../fly/screen_awake.dart'; import '../../fly/calibration.dart' show CalibrationKind; import '../../fly/flight_trail.dart'; import '../../fly/fly_zone_loader.dart'; @@ -188,6 +189,7 @@ class _FlyScreenState extends State { @override void initState() { super.initState(); + ScreenAwake.keepOn(true); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, @@ -549,6 +551,7 @@ class _FlyScreenState extends State { @override void dispose() { + ScreenAwake.keepOn(false); _m.removeListener(_onModel); _m.hudHidden.removeListener(_onHudHidden); _m.mission.removeListener(_onMission); diff --git a/Fly App/lib/ui/fly/modes/vision/vision_panel.dart b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart index 5bdd19c..9853012 100644 --- a/Fly App/lib/ui/fly/modes/vision/vision_panel.dart +++ b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../flight_model.dart'; @@ -33,12 +35,19 @@ Future startVisionMode( if (r == null) return; } try { - await start(); + // A start the aircraft never answers (TapFly on a Mavic Pro in the + // simulator) would otherwise leave GO looking dead. + await start().timeout(startTimeout); + } on TimeoutException { + onError(title, 'the aircraft did not answer; try again or fly by hand'); } catch (e) { onError(title, e); } } +/// How long [startVisionMode] waits for the aircraft to accept a start. +const Duration startTimeout = Duration(seconds: 15); + /// The ActiveTrack / TapFly panel at the head of the left column: what the /// operator is doing, the follow style or direction, TapFly's speed, Stop /// while it flies, and Exit. diff --git a/Fly App/test/fly/p7_modes_test.dart b/Fly App/test/fly/p7_modes_test.dart index d1a93e0..74dc444 100644 --- a/Fly App/test/fly/p7_modes_test.dart +++ b/Fly App/test/fly/p7_modes_test.dart @@ -153,6 +153,13 @@ void main() { expect(dji.calls.last, 'setKey(CINEMATIC_MODE_ENABLED, false)'); }); + test('Tripod waits for take-off; Cinematic does not', () { + final SafetyGate g = SafetyGate(model: _model(telemetryTick()..['isFlying'] = false), dji: FakeDjiService()); + expect(_blockers(g.checkSlowMode(cinematic: false, on: true)), contains(contains('Take off first'))); + expect(_blockers(g.checkSlowMode(cinematic: true, on: true)), isEmpty); + expect(_blockers(g.checkSlowMode(cinematic: false, on: false)), isEmpty, reason: 'turning it off is always allowed'); + }); + test('a Tripod write the aircraft ignores is reported, not silent', () async { final _KeepsTripodOff dji = _KeepsTripodOff(); final SafetyGate g = SafetyGate(model: _model(telemetryTick()), dji: dji); diff --git a/Fly App/test/fly/vision_modes_test.dart b/Fly App/test/fly/vision_modes_test.dart index 21601ad..d34b077 100644 --- a/Fly App/test/fly/vision_modes_test.dart +++ b/Fly App/test/fly/vision_modes_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pilotvault_fly/flight_model.dart'; @@ -11,6 +13,7 @@ import 'package:pilotvault_fly/fly/state/mission_state.dart'; import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:pilotvault_fly/ui/fly/modes/vision/vision_panel.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'fake_dji.dart'; @@ -444,4 +447,29 @@ void main() { expect(dji.calls.last, 'stopTapFly'); }); }); + + testWidgets('a vision start the aircraft never answers reports an error', (WidgetTester tester) async { + final List errors = []; + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => startVisionMode( + context, + title: 'TapFly ยท Forward', + icon: 'chevronsRight', + description: '', + checks: const [], + start: () => Completer().future, + onError: (String label, Object e) => errors.add('$label: $e'), + ), + child: const Text('GO'), + ), + ), + )); + await tester.tap(find.text('GO')); + await tester.pump(const Duration(seconds: 14)); + expect(errors, isEmpty); + await tester.pump(const Duration(seconds: 2)); + expect(errors.single, contains('did not answer')); + }); }