diff --git a/chameleonultragui/android/app/src/main/AndroidManifest.xml b/chameleonultragui/android/app/src/main/AndroidManifest.xml index bc0b775e9..4bf2793c0 100644 --- a/chameleonultragui/android/app/src/main/AndroidManifest.xml +++ b/chameleonultragui/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/chameleonultragui/lib/connector/serial_ble.dart b/chameleonultragui/lib/connector/serial_ble.dart index c3f29d675..16490f728 100644 --- a/chameleonultragui/lib/connector/serial_ble.dart +++ b/chameleonultragui/lib/connector/serial_ble.dart @@ -244,6 +244,17 @@ class BLESerial extends AbstractSerial { connectionType = ConnectionType.ble; isDFU = false; + // Prefer lower latency / harder-to-kill link on Android. + // Idle BLE is often dropped when the phone sleeps or Doze runs. + try { + await flutterReactiveBle.requestConnectionPriority( + deviceId: connectionState.deviceId, + priority: ConnectionPriority.highPerformance, + ); + } catch (e) { + log.w("requestConnectionPriority failed: $e"); + } + completer.complete(true); } catch (_) { try { diff --git a/chameleonultragui/lib/helpers/connection_keepalive.dart b/chameleonultragui/lib/helpers/connection_keepalive.dart new file mode 100644 index 000000000..0284efa88 --- /dev/null +++ b/chameleonultragui/lib/helpers/connection_keepalive.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:chameleonultragui/bridge/chameleon.dart'; +import 'package:chameleonultragui/connector/serial_abstract.dart'; +import 'package:logger/logger.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +/// Keeps the Chameleon link healthy while the app is open and connected. +/// +/// Firmware note: the device will not enter system-off sleep while BLE is +/// connected (or USB powered). Premature "device sleep" on Android is almost +/// always the phone/OS dropping an idle BLE link first, then the device +/// sleeping ~4s after disconnect. +/// +/// This helper: +/// - holds a wakelock while connected + app is in foreground +/// - sends a lightweight command periodically so Android does not kill idle BLE +/// - soft-fails transient BLE glitches (retries before disconnecting) +class ConnectionKeepAlive { + ConnectionKeepAlive({ + this.interval = const Duration(seconds: 12), + this.attemptsPerPing = 3, + this.retryDelay = const Duration(milliseconds: 800), + this.maxConsecutiveFailures = 3, + }); + + /// How often to send a keep-alive when connected. + final Duration interval; + + /// Retries inside a single keep-alive tick before counting a failure. + final int attemptsPerPing; + + /// Delay between retries within one tick. + final Duration retryDelay; + + /// Disconnect only after this many consecutive failed ticks + /// (each tick already retried [attemptsPerPing] times). + final int maxConsecutiveFailures; + + Timer? _timer; + bool _wakelockHeld = false; + bool _pingInFlight = false; + int _consecutiveFailures = 0; + + ChameleonCommunicator? _communicator; + AbstractSerial? _connector; + Logger? _log; + + bool get isActive => _timer != null; + + /// Sync keep-alive with current connection + app lifecycle state. + void sync({ + required bool connected, + required bool appInForeground, + ChameleonCommunicator? communicator, + AbstractSerial? connector, + Logger? log, + bool forceWakelock = false, + }) { + _communicator = communicator; + _connector = connector; + _log = log; + + final shouldKeepAlive = connected && + appInForeground && + communicator != null && + connector != null && + connector.connected; + + if (shouldKeepAlive || forceWakelock) { + _setWakelock(true); + } else { + _setWakelock(false); + } + + if (shouldKeepAlive) { + _ensureTimer(); + } else { + _stopTimer(); + } + } + + void dispose() { + _stopTimer(); + _setWakelock(false); + _communicator = null; + _connector = null; + _log = null; + } + + void _ensureTimer() { + if (_timer != null) { + return; + } + + _consecutiveFailures = 0; + _log?.d( + 'Connection keep-alive started (interval=${interval.inSeconds}s, ' + 'retries=$attemptsPerPing, maxFails=$maxConsecutiveFailures)'); + // Immediate ping after connect stabilizes the link, then periodic. + unawaited(_ping()); + _timer = Timer.periodic(interval, (_) { + unawaited(_ping()); + }); + } + + void _stopTimer() { + if (_timer != null) { + _timer!.cancel(); + _timer = null; + _consecutiveFailures = 0; + _log?.d('Connection keep-alive stopped'); + } + } + + Future _ping() async { + final communicator = _communicator; + final connector = _connector; + final log = _log; + + if (_pingInFlight || + communicator == null || + connector == null || + !connector.connected) { + return; + } + + _pingInFlight = true; + try { + for (var attempt = 1; attempt <= attemptsPerPing; attempt++) { + if (!connector.connected) { + return; + } + try { + // Lightweight firmware command — any successful round-trip keeps BLE busy. + await communicator.getFirmwareVersion(); + if (_consecutiveFailures > 0) { + log?.d( + 'Connection keep-alive recovered after $_consecutiveFailures failed tick(s)'); + } + _consecutiveFailures = 0; + return; + } catch (e) { + log?.w( + 'Connection keep-alive ping attempt $attempt/$attemptsPerPing failed: $e'); + if (attempt < attemptsPerPing) { + await Future.delayed(retryDelay); + } + } + } + + // All retries in this tick failed — soft-fail unless threshold reached. + _consecutiveFailures++; + if (_consecutiveFailures < maxConsecutiveFailures) { + log?.w( + 'Connection keep-alive soft-fail ' + '$_consecutiveFailures/$maxConsecutiveFailures (not disconnecting yet)'); + return; + } + + log?.w( + 'Connection keep-alive failed $maxConsecutiveFailures ticks in a row; disconnecting'); + try { + if (connector.connected) { + await connector.performDisconnect(); + } + } catch (_) {} + _consecutiveFailures = 0; + } finally { + _pingInFlight = false; + } + } + + void _setWakelock(bool enable) { + if (_wakelockHeld == enable) { + return; + } + _wakelockHeld = enable; + try { + WakelockPlus.toggle(enable: enable); + _log?.d('Wakelock ${enable ? "enabled" : "disabled"}'); + } catch (e) { + _log?.w('Wakelock toggle failed: $e'); + } + } +} diff --git a/chameleonultragui/lib/main.dart b/chameleonultragui/lib/main.dart index fe799078a..f64650c17 100644 --- a/chameleonultragui/lib/main.dart +++ b/chameleonultragui/lib/main.dart @@ -6,12 +6,12 @@ import 'package:chameleonultragui/connector/serial_ble.dart'; import 'package:chameleonultragui/connector/serial_emulator.dart'; import 'package:chameleonultragui/connector/serial_macos.dart'; import 'package:chameleonultragui/gui/page/tools.dart'; +import 'package:chameleonultragui/helpers/connection_keepalive.dart'; import 'package:chameleonultragui/helpers/font.dart'; import 'package:chameleonultragui/helpers/general.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import 'connector/serial_native.dart'; @@ -146,21 +146,50 @@ class MainPage extends StatefulWidget { State createState() => _MainPageState(); } -class _MainPageState extends State { +class _MainPageState extends State with WidgetsBindingObserver { var selectedIndex = 0; + final ConnectionKeepAlive _keepAlive = ConnectionKeepAlive(); + bool _appInForeground = true; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance .addPostFrameCallback((_) => updateNavigationRailWidth(context)); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _keepAlive.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appInForeground = state == AppLifecycleState.resumed || + state == AppLifecycleState.inactive; + _syncKeepAlive(Provider.of(context, listen: false)); + } + + void _syncKeepAlive(ChameleonGUIState appState, {bool forceWakelock = false}) { + _keepAlive.sync( + connected: appState.connector?.connected == true, + appInForeground: _appInForeground, + communicator: appState.communicator, + connector: appState.connector, + log: appState.log, + forceWakelock: forceWakelock, + ); + } + @override void reassemble() async { // Disconnect on reload var appState = Provider.of(context, listen: false); await appState.disconnect(); + _syncKeepAlive(appState); super.reassemble(); } @@ -273,9 +302,14 @@ class _MainPageState extends State { throw UnimplementedError('no widget for $selectedIndex'); } - try { - WakelockPlus.toggle(enable: page is FlashingPage); - } catch (_) {} + // Keep phone awake + BLE link busy while connected and app is open. + // DFU flashing always forces wakelock even if communicator is not ready. + // Schedule after frame to avoid side-effects during build. + final forceWakelock = page is FlashingPage; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncKeepAlive(appState, forceWakelock: forceWakelock); + }); return MaterialApp( title: 'Chameleon Ultra GUI', // App Name