Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## v1.7.1 — Background Persistence & Camera Fix

> Requires Android 10+ (API 29)

### Node Background Persistence

- **Lifecycle-Aware Reconnection** — Handles both `resumed` and `paused` lifecycle states; forces connection health check on app resume since Dart timers freeze while backgrounded
- **Foreground Service Verification** — Watchdog, resume handler, and pause handler all verify the Android foreground service is still alive and restart it if killed
- **Stale Connection Recovery** — On app resume, detects if the WebSocket went stale (no data for 90s+) and forces a full reconnect instead of silently staying in "paired" state
- **Live Notification Status** — Foreground notification text updates in real-time to reflect node state (connected, connecting, reconnecting, error)

### Camera Fix

- **Immediate Camera Release** — Camera hardware is now released immediately after each snap/clip using `try/finally`, preventing "Failed to submit capture request" errors on repeated use
- **Auto-Exposure Settle** — Added 500ms settle time before snap for proper auto-exposure/focus
- **Flash Conflict Prevention** — Flash capability releases the camera when torch is turned off, so subsequent snap/clip operations don't conflict
- **Stale Controller Recovery** — Flash capability detects errored/stale controllers and recreates them instead of failing silently

---

## v1.7.0 — Clean Modern UI Redesign

> Requires Android 10+ (API 29)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenClaw

[![Download APK](https://img.shields.io/badge/Download-APK-green?style=for-the-badge&logo=android)](https://github.com/mithun50/openclaw-termux/releases/download/v1.6.1/OpenClaw-v1.6.1-universal.apk)
[![Download APK](https://img.shields.io/badge/Download-APK-green?style=for-the-badge&logo=android)](https://github.com/mithun50/openclaw-termux/releases/latest)
[![Build Flutter APK & AAB](https://github.com/mithun50/openclaw-termux/actions/workflows/flutter-build.yml/badge.svg)](https://github.com/mithun50/openclaw-termux/actions/workflows/flutter-build.yml)
[![npm version](https://img.shields.io/npm/v/openclaw-termux?color=blue&label=npm)](https://www.npmjs.com/package/openclaw-termux)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Expand Down Expand Up @@ -259,6 +259,7 @@ flutter_app/lib/
│ └── vibration_capability.dart # Haptic feedback
└── widgets/
├── gateway_controls.dart # Start/stop, URL display, copy button
├── node_controls.dart # Node enable/disable, status badge
├── terminal_toolbar.dart # Extra keys (Tab, Ctrl, Esc, arrows)
├── status_card.dart # Reusable status card
└── progress_step.dart # Setup wizard step indicator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ class MainActivity : FlutterActivity() {
"isNodeServiceRunning" -> {
result.success(NodeForegroundService.isRunning)
}
"updateNodeNotification" -> {
val text = call.argument<String>("text") ?: "Node connected"
NodeForegroundService.updateStatus(text)
result.success(true)
}
"requestBatteryOptimization" -> {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class NodeForegroundService : Service() {
const val NOTIFICATION_ID = 3
var isRunning = false
private set
private var instance: NodeForegroundService? = null

fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
Expand All @@ -31,6 +32,10 @@ class NodeForegroundService : Service() {
val intent = Intent(context, NodeForegroundService::class.java)
context.stopService(intent)
}

fun updateStatus(text: String) {
instance?.updateNotification(text)
}
}

private var wakeLock: PowerManager.WakeLock? = null
Expand All @@ -45,6 +50,7 @@ class NodeForegroundService : Service() {

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isRunning = true
instance = this
startTime = System.currentTimeMillis()
startForeground(NOTIFICATION_ID, buildNotification("Node connected"))
acquireWakeLock()
Expand All @@ -53,10 +59,18 @@ class NodeForegroundService : Service() {

override fun onDestroy() {
isRunning = false
instance = null
releaseWakeLock()
super.onDestroy()
}

private fun updateNotification(text: String) {
try {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(text))
} catch (_: Exception) {}
}

private fun acquireWakeLock() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
Expand Down
2 changes: 1 addition & 1 deletion flutter_app/lib/constants.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class AppConstants {
static const String appName = 'OpenClaw';
static const String version = '1.7.0';
static const String version = '1.7.1';
static const String packageName = 'com.nxg.openclawproot';

/// Matches ANSI escape sequences (e.g. color codes in terminal output).
Expand Down
95 changes: 88 additions & 7 deletions flutter_app/lib/providers/node_provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/gateway_state.dart';
Expand Down Expand Up @@ -38,20 +37,87 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
_subscription = _nodeService.stateStream.listen((state) {
_state = state;
_updateServiceNotification(state);
notifyListeners();
});
_registerCapabilities();
_init();
}

/// Keep the foreground notification text in sync with the node status.
void _updateServiceNotification(NodeState state) {
if (state.isDisabled) return;
String text;
switch (state.status) {
case NodeStatus.paired:
text = 'Node connected';
break;
case NodeStatus.connecting:
case NodeStatus.challenging:
case NodeStatus.pairing:
text = 'Node connecting...';
break;
case NodeStatus.disconnected:
text = 'Node reconnecting...';
break;
case NodeStatus.error:
text = 'Node error — retrying';
break;
default:
return;
}
try {
NativeBridge.updateNodeNotification(text);
} catch (_) {}
}

@override
void didChangeAppLifecycleState(AppLifecycleState lifecycleState) {
if (lifecycleState == AppLifecycleState.resumed) {
// App came back to foreground — reconnect if the connection dropped
if (!_state.isPaired && !_state.isDisabled && !_state.isConnecting) {
_checkAutoConnect();
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_onAppResumed();
} else if (state == AppLifecycleState.paused) {
_onAppPaused();
}
}

/// App returned to foreground — force connection health check.
/// Dart timers freeze while backgrounded, so the watchdog and ping
/// timers won't have fired. We must check and reconnect manually.
Future<void> _onAppResumed() async {
if (_state.isDisabled) return;

// Ensure the foreground service is still alive
try {
final running = await NativeBridge.isNodeServiceRunning();
if (!running) {
await NativeBridge.startNodeService();
}
} catch (_) {}

if (_state.isPaired && _nodeService.isConnectionStale) {
// WebSocket went stale while in background — force reconnect
await _nodeService.disconnect();
await _nodeService.connect();
} else if (!_state.isPaired && !_state.isConnecting) {
// Connection dropped while in background
await _nodeService.connect();
}

// Restart watchdog (may have been frozen)
_startWatchdog();
}

/// App going to background — ensure the foreground service is running
/// so Android keeps our process alive.
Future<void> _onAppPaused() async {
if (_state.isDisabled) return;

try {
final running = await NativeBridge.isNodeServiceRunning();
if (!running) {
await NativeBridge.startNodeService();
}
} catch (_) {}
}

void _registerCapabilities() {
Expand Down Expand Up @@ -126,6 +192,13 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
await prefs.init();
if (prefs.nodeEnabled) {
await _requestNodePermissions();
// Ensure foreground service is running before connecting
try {
final running = await NativeBridge.isNodeServiceRunning();
if (!running) {
await NativeBridge.startNodeService();
}
} catch (_) {}
await _nodeService.connect();
_startWatchdog();
}
Expand Down Expand Up @@ -158,9 +231,17 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
/// 2. Node appears paired but WebSocket is stale (no data for 90s+)
void _startWatchdog() {
_watchdog?.cancel();
_watchdog = Timer.periodic(const Duration(seconds: 45), (_) {
_watchdog = Timer.periodic(const Duration(seconds: 45), (_) async {
if (_state.isDisabled) return;

// Also verify foreground service is still alive
try {
final running = await NativeBridge.isNodeServiceRunning();
if (!running && !_state.isDisabled) {
await NativeBridge.startNodeService();
}
} catch (_) {}

if (!_state.isPaired && !_state.isConnecting) {
// Connection dropped — reconnect
_nodeService.connect();
Expand Down
40 changes: 21 additions & 19 deletions flutter_app/lib/services/capabilities/camera_capability.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import '../../models/node_frame.dart';
import 'capability_handler.dart';

class CameraCapability extends CapabilityHandler {
CameraController? _controller;
List<CameraDescription>? _cameras;

@override
Expand All @@ -30,11 +29,12 @@ class CameraCapability extends CapabilityHandler {
return status.isGranted;
}

Future<CameraController> _getController({String? facing}) async {
/// Create a fresh controller for each operation. The caller MUST dispose it
/// when done so the camera hardware is released immediately.
Future<CameraController> _createController({String? facing}) async {
_cameras ??= await availableCameras();
if (_cameras!.isEmpty) throw Exception('No camera available');

// Select camera based on facing param
final direction = facing == 'front'
? CameraLensDirection.front
: CameraLensDirection.back;
Expand All @@ -43,18 +43,9 @@ class CameraCapability extends CapabilityHandler {
orElse: () => _cameras!.first,
);

// Reuse existing controller if it matches the requested camera
if (_controller != null &&
_controller!.value.isInitialized &&
_controller!.description == target) {
return _controller!;
}

// Dispose old controller if switching cameras
_controller?.dispose();
_controller = CameraController(target, ResolutionPreset.medium);
await _controller!.initialize();
return _controller!;
final controller = CameraController(target, ResolutionPreset.medium);
await controller.initialize();
return controller;
}

@override
Expand Down Expand Up @@ -93,9 +84,14 @@ class CameraCapability extends CapabilityHandler {
}

Future<NodeFrame> _snap(Map<String, dynamic> params) async {
CameraController? controller;
try {
final facing = params['facing'] as String?;
final controller = await _getController(facing: facing);
controller = await _createController(facing: facing);

// Brief settle time for auto-exposure/focus
await Future.delayed(const Duration(milliseconds: 500));

final file = await controller.takePicture();
final bytes = await File(file.path).readAsBytes();
final b64 = base64Encode(bytes);
Expand All @@ -120,14 +116,18 @@ class CameraCapability extends CapabilityHandler {
'code': 'CAMERA_ERROR',
'message': '$e',
});
} finally {
// Always release the camera
await controller?.dispose();
}
}

Future<NodeFrame> _clip(Map<String, dynamic> params) async {
CameraController? controller;
try {
final durationMs = params['durationMs'] as int? ?? 5000;
final facing = params['facing'] as String?;
final controller = await _getController(facing: facing);
controller = await _createController(facing: facing);
await controller.startVideoRecording();
await Future.delayed(Duration(milliseconds: durationMs));
final file = await controller.stopVideoRecording();
Expand All @@ -145,11 +145,13 @@ class CameraCapability extends CapabilityHandler {
'code': 'CAMERA_ERROR',
'message': '$e',
});
} finally {
// Always release the camera
await controller?.dispose();
}
}

void dispose() {
_controller?.dispose();
_controller = null;
// No persistent controller to clean up anymore
}
}
22 changes: 20 additions & 2 deletions flutter_app/lib/services/capabilities/flash_capability.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ class FlashCapability extends CapabilityHandler {
}

Future<CameraController> _getController() async {
if (_controller != null && _controller!.value.isInitialized) {
return _controller!;
// Verify existing controller is still usable
if (_controller != null) {
if (_controller!.value.isInitialized && !_controller!.value.hasError) {
return _controller!;
}
// Controller is stale/errored — dispose and recreate
try { _controller!.dispose(); } catch (_) {}
_controller = null;
}

_cameras ??= await availableCameras();
if (_cameras!.isEmpty) throw Exception('No camera available');
// Use back camera for flash/torch
Expand Down Expand Up @@ -68,8 +75,19 @@ class FlashCapability extends CapabilityHandler {
final controller = await _getController();
await controller.setFlashMode(on ? FlashMode.torch : FlashMode.off);
_torchOn = on;

// If turning off, release the camera so it doesn't block snap/clip
if (!on) {
_controller?.dispose();
_controller = null;
}

return NodeFrame.response('', payload: {'on': _torchOn});
} catch (e) {
// If it failed, dispose and reset so next attempt gets a fresh controller
try { _controller?.dispose(); } catch (_) {}
_controller = null;
_torchOn = false;
return NodeFrame.response('', error: {
'code': 'FLASH_ERROR',
'message': '$e',
Expand Down
4 changes: 4 additions & 0 deletions flutter_app/lib/services/native_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ class NativeBridge {
return await _channel.invokeMethod('isNodeServiceRunning');
}

static Future<bool> updateNodeNotification(String text) async {
return await _channel.invokeMethod('updateNodeNotification', {'text': text});
}

static Future<bool> requestBatteryOptimization() async {
return await _channel.invokeMethod('requestBatteryOptimization');
}
Expand Down
4 changes: 1 addition & 3 deletions flutter_app/lib/services/node_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,7 @@ class NodeService {
final deviceToken = prefs.nodeDeviceToken;

// For local connections, read the gateway auth token from dashboard URL
if (_gatewayAuthToken == null) {
_gatewayAuthToken = await _readGatewayToken();
}
_gatewayAuthToken ??= await _readGatewayToken();

// Prefer gateway auth token (exact match); fall back to device token
// (gateway verifies device tokens as fallback if gateway token check fails)
Expand Down
Loading
Loading