Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chameleonultragui/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.chameleon.ultra">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
Expand Down
11 changes: 11 additions & 0 deletions chameleonultragui/lib/connector/serial_ble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
186 changes: 186 additions & 0 deletions chameleonultragui/lib/helpers/connection_keepalive.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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');
}
}
}
44 changes: 39 additions & 5 deletions chameleonultragui/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -146,21 +146,50 @@ class MainPage extends StatefulWidget {
State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
class _MainPageState extends State<MainPage> 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<ChameleonGUIState>(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<ChameleonGUIState>(context, listen: false);
await appState.disconnect();
_syncKeepAlive(appState);

super.reassemble();
}
Expand Down Expand Up @@ -273,9 +302,14 @@ class _MainPageState extends State<MainPage> {
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
Expand Down
Loading