diff --git a/CHANGELOG.md b/CHANGELOG.md index 75edf92..f6d68ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## v1.8.4 — Serial, Log Timestamps & ADB Backup + +### New Features + +- **Serial over Bluetooth & USB (#21)** — New `serial` node capability with 5 commands (`list`, `connect`, `disconnect`, `write`, `read`). Supports USB serial devices via `usb_serial` and BLE devices via Nordic UART Service (flutter_blue_plus). Device IDs prefixed with `usb:` or `ble:` for disambiguation +- **Gateway Log Timestamps (#54)** — All gateway log messages (both Kotlin and Dart side) now include ISO 8601 UTC timestamps for easier debugging +- **ADB Backup Support (#55)** — Added `android:allowBackup="true"` to AndroidManifest so users can back up app data via `adb backup` + +### Enhancements + +- **Check for Updates (#59)** — New "Check for Updates" option in Settings > About. Queries the GitHub Releases API, compares semver versions, and shows an update dialog with a download link if a newer release is available + +### Bug Fixes + +- **Node Capabilities Not Available to AI (#56)** — `_writeNodeAllowConfig()` silently failed when proot/node wasn't ready, causing the gateway to start with no `allowCommands`. Added direct file I/O fallback to write `openclaw.json` directly on the Android filesystem. Also fixed `node.capabilities` event to send both `commands` and `caps` fields matching the connect frame format + +### Node Command Reference Update + +| Capability | Commands | +|------------|----------| +| Serial | `serial.list`, `serial.connect`, `serial.disconnect`, `serial.write`, `serial.read` | + +--- + ## v1.8.3 — Multi-Instance Guard ### Bug Fixes diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml index dd4577a..63c1511 100644 --- a/flutter_app/android/app/src/main/AndroidManifest.xml +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,11 @@ + + + + + + android:extractNativeLibs="true" + android:allowBackup="true" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + socket.connect(InetSocketAddress("127.0.0.1", port), 1000) + true + } + } catch (_: Exception) { + false + } + } + private fun startGateway() { - if (gatewayProcess?.isAlive == true) return - isRunning = true - instance = this - startTime = System.currentTimeMillis() + synchronized(lock) { + if (stopping) return + if (gatewayProcess?.isAlive == true) return - Thread { + isRunning = true + instance = this + startTime = System.currentTimeMillis() + } + + gatewayThread = Thread { try { + // Check if an existing gateway is already listening on the port. + // Moved inside thread to avoid blocking the main thread (#60). + if (isPortInUse()) { + emitLog("[INFO] Gateway already running on port 18789, adopting existing instance") + updateNotificationRunning() + startUptimeTicker() + startWatchdog() + return@Thread + } + + emitLog("[INFO] Setting up environment...") val filesDir = applicationContext.filesDir.absolutePath val nativeLibDir = applicationContext.applicationInfo.nativeLibraryDir val pm = ProcessManager(filesDir, nativeLibDir) @@ -92,6 +150,7 @@ class GatewayService : Service() { val bootstrapManager = BootstrapManager(applicationContext, filesDir, nativeLibDir) try { bootstrapManager.setupDirectories() + emitLog("[INFO] Directories ready") } catch (e: Exception) { emitLog("[WARN] setupDirectories failed: ${e.message}") } @@ -122,13 +181,33 @@ class GatewayService : Service() { } } catch (_: Exception) {} - gatewayProcess = pm.startProotProcess("openclaw gateway --verbose") + // Abort if stop was requested during setup + if (stopping) return@Thread + + // Final check right before launch — another instance may have + // started between the first check and now + if (isPortInUse()) { + emitLog("Gateway already running on port 18789, skipping launch") + updateNotificationRunning() + startUptimeTicker() + startWatchdog() + return@Thread + } + + emitLog("[INFO] Spawning proot process...") + synchronized(lock) { + if (stopping) return@Thread + processStartTime = System.currentTimeMillis() + gatewayProcess = pm.startProotProcess("openclaw gateway --verbose") + } updateNotificationRunning() - emitLog("Gateway started") + emitLog("[INFO] Gateway process spawned") startUptimeTicker() + startWatchdog() // Read stdout - val stdoutReader = BufferedReader(InputStreamReader(gatewayProcess!!.inputStream)) + val proc = gatewayProcess!! + val stdoutReader = BufferedReader(InputStreamReader(proc.inputStream)) Thread { try { var line: String? @@ -139,54 +218,106 @@ class GatewayService : Service() { } catch (_: Exception) {} }.start() - // Read stderr - val stderrReader = BufferedReader(InputStreamReader(gatewayProcess!!.errorStream)) + // Read stderr — log all lines on first attempt for debugging visibility + val stderrReader = BufferedReader(InputStreamReader(proc.errorStream)) + val currentRestartCount = restartCount Thread { try { var line: String? while (stderrReader.readLine().also { line = it } != null) { val l = line ?: continue - if (!l.contains("proot warning") && !l.contains("can't sanitize")) { + if (currentRestartCount == 0 || + (!l.contains("proot warning") && !l.contains("can't sanitize"))) { emitLog("[ERR] $l") } } } catch (_: Exception) {} }.start() - val exitCode = gatewayProcess!!.waitFor() - emitLog("Gateway exited with code $exitCode") + val exitCode = proc.waitFor() + val uptimeMs = System.currentTimeMillis() - processStartTime + val uptimeSec = uptimeMs / 1000 + emitLog("[INFO] Gateway exited with code $exitCode (uptime: ${uptimeSec}s)") + + // If stop was requested, don't auto-restart + if (stopping) return@Thread + + // If the gateway ran for >60s, it was a transient crash — reset counter + if (uptimeMs > 60_000) { + restartCount = 0 + } if (isRunning && restartCount < maxRestarts) { restartCount++ - val delayMs = 2000L * (1 shl (restartCount - 1)) // 2s, 4s, 8s - emitLog("Auto-restarting in ${delayMs / 1000}s (attempt $restartCount/$maxRestarts)...") + // Cap delay at 16s to avoid excessively long waits + val delayMs = minOf(2000L * (1 shl (restartCount - 1)), 16000L) + emitLog("[INFO] Auto-restarting in ${delayMs / 1000}s (attempt $restartCount/$maxRestarts)...") updateNotification("Restarting in ${delayMs / 1000}s (attempt $restartCount)...") Thread.sleep(delayMs) - startGateway() + if (!stopping) { + startTime = System.currentTimeMillis() + startGateway() + } } else if (restartCount >= maxRestarts) { - emitLog("Max restarts reached. Gateway stopped.") + emitLog("[WARN] Max restarts reached. Gateway stopped.") updateNotification("Gateway stopped (crashed)") isRunning = false } } catch (e: Exception) { - emitLog("Gateway error: ${e.message}") - isRunning = false - updateNotification("Gateway error") + if (!stopping) { + emitLog("[ERROR] Gateway error: ${e.message}") + isRunning = false + updateNotification("Gateway error") + } } - }.start() + }.also { it.start() } } private fun stopGateway() { - restartCount = maxRestarts // Prevent auto-restart - uptimeThread?.interrupt() - uptimeThread = null - gatewayProcess?.let { - it.destroyForcibly() - gatewayProcess = null + synchronized(lock) { + stopping = true + restartCount = maxRestarts // Prevent auto-restart + uptimeThread?.interrupt() + uptimeThread = null + watchdogThread?.interrupt() + watchdogThread = null + gatewayProcess?.let { + try { + it.destroyForcibly() + } catch (_: Exception) {} + gatewayProcess = null + } } emitLog("Gateway stopped by user") } + /** Watchdog: periodically checks if the proot process is alive. + * If the process dies and the waitFor() thread hasn't noticed yet, + * this ensures isRunning is updated promptly. */ + private fun startWatchdog() { + watchdogThread?.interrupt() + watchdogThread = Thread { + try { + // Wait 45s before first check — give the process time to start + Thread.sleep(45_000) + while (!Thread.interrupted() && isRunning && !stopping) { + val proc = gatewayProcess + if (proc != null && !proc.isAlive) { + // Process died — the waitFor() thread should handle restart, + // but update the flag in case it's stuck + emitLog("[WARN] Watchdog: gateway process not alive") + break + } + // Also check if port is still responding after initial startup + if (proc != null && !isPortInUse()) { + emitLog("[WARN] Watchdog: port 18789 not responding") + } + Thread.sleep(15_000) // Check every 15s + } + } catch (_: InterruptedException) {} + }.apply { isDaemon = true; start() } + } + private fun startUptimeTicker() { uptimeThread?.interrupt() uptimeThread = Thread { @@ -217,9 +348,17 @@ class GatewayService : Service() { updateNotification("Running on port 18789 \u2022 ${formatUptime()}") } + /** Emit a log message to the Flutter EventChannel. + * MUST post to main thread — EventSink.success() is not thread-safe. */ private fun emitLog(message: String) { try { - logSink?.success(message) + val ts = java.time.Instant.now().toString() + val formatted = "$ts $message" + mainHandler.post { + try { + logSink?.success(formatted) + } catch (_: Exception) {} + } } catch (_: Exception) {} } diff --git a/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/MainActivity.kt index 708b09f..e62e58a 100644 --- a/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/MainActivity.kt @@ -128,7 +128,7 @@ class MainActivity : FlutterActivity() { } } "isGatewayRunning" -> { - result.success(GatewayService.isRunning) + result.success(GatewayService.isProcessAlive()) } "startTerminalService" -> { try { @@ -461,6 +461,17 @@ class MainActivity : FlutterActivity() { result.error("INVALID_ARGS", "path and content required", null) } } + "bringToForeground" -> { + try { + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + applicationContext.startActivity(intent) + result.success(true) + } catch (e: Exception) { + result.error("FOREGROUND_ERROR", e.message, null) + } + } "readSensor" -> { val sensorType = call.argument("sensor") ?: "accelerometer" Thread { @@ -530,7 +541,6 @@ class MainActivity : FlutterActivity() { createUrlNotificationChannel() requestNotificationPermission() - requestStoragePermissionOnLaunch() EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL).setStreamHandler( object : EventChannel.StreamHandler { @@ -558,30 +568,6 @@ class MainActivity : FlutterActivity() { } } - private fun requestStoragePermissionOnLaunch() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (!Environment.isExternalStorageManager()) { - try { - val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - startActivity(intent) - } catch (_: Exception) {} - } - } else { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ), - STORAGE_PERMISSION_REQUEST - ) - } - } - } - private fun createUrlNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( diff --git a/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/SshForegroundService.kt b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/SshForegroundService.kt index e6cedda..4dc68fe 100644 --- a/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/SshForegroundService.kt +++ b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/SshForegroundService.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.os.Build import android.os.IBinder import android.os.PowerManager +import android.net.ConnectivityManager import java.io.BufferedReader import java.io.File import java.io.InputStreamReader @@ -114,52 +115,71 @@ class SshForegroundService : Service() { try { bootstrapManager.setupDirectories() } catch (_: Exception) {} try { bootstrapManager.writeResolvConf() } catch (_: Exception) {} - // Last-resort: verify resolv.conf exists, create inline if not - val resolvContent = "nameserver 8.8.8.8\nnameserver 8.8.4.4\n" + // Last-resort: verify resolv.conf exists, create inline if not. + // Use system DNS servers when available (#60). + val resolvContent = getSystemDnsContent() try { val resolvFile = File(filesDir, "config/resolv.conf") - if (!resolvFile.exists() || resolvFile.length() == 0L) { - resolvFile.parentFile?.mkdirs() - resolvFile.writeText(resolvContent) - } + resolvFile.parentFile?.mkdirs() + resolvFile.writeText(resolvContent) } catch (_: Exception) {} // Also write into rootfs /etc/ so DNS works even if bind-mount fails try { val rootfsResolv = File(filesDir, "rootfs/ubuntu/etc/resolv.conf") - if (!rootfsResolv.exists() || rootfsResolv.length() == 0L) { - rootfsResolv.parentFile?.mkdirs() - rootfsResolv.writeText(resolvContent) - } + rootfsResolv.parentFile?.mkdirs() + rootfsResolv.writeText(resolvContent) } catch (_: Exception) {} // Generate host keys if missing, configure sshd, then run in // foreground mode (-D) so the proot process stays alive. // -e logs to stderr instead of syslog (easier debugging). // PermitRootLogin yes is needed since proot fakes root. + // ListenAddress 0.0.0.0 ensures sshd binds to all IPv4 + // interfaces and survives VPN network changes (#61). val cmd = "mkdir -p /run/sshd /etc/ssh && " + "test -f /etc/ssh/ssh_host_rsa_key || ssh-keygen -A && " + "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config 2>/dev/null; " + "grep -q '^PermitRootLogin' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config; " + + "sed -i 's/^#\\?ListenAddress.*/ListenAddress 0.0.0.0/' /etc/ssh/sshd_config 2>/dev/null; " + + "grep -q '^ListenAddress' /etc/ssh/sshd_config || echo 'ListenAddress 0.0.0.0' >> /etc/ssh/sshd_config; " + "/usr/sbin/sshd -D -e -p $port" - sshdProcess = pm.startProotProcess(cmd) - updateNotification("SSH running on port $port") - - // Read stderr for logs - val stderrReader = BufferedReader(InputStreamReader(sshdProcess!!.errorStream)) - Thread { - try { - var line: String? - while (stderrReader.readLine().also { line = it } != null) { - // Could log these if needed - } - } catch (_: Exception) {} - }.start() - - val exitCode = sshdProcess!!.waitFor() - isRunning = false - updateNotification("SSH stopped (exit $exitCode)") - stopSelf() + var restartCount = 0 + val maxRestarts = 3 + + while (restartCount <= maxRestarts) { + sshdProcess = pm.startProotProcess(cmd) + if (restartCount == 0) { + updateNotification("SSH running on port $port") + } else { + updateNotification("SSH restarted on port $port (attempt ${restartCount + 1})") + } + + // Read stderr for logs + val stderrReader = BufferedReader(InputStreamReader(sshdProcess!!.errorStream)) + Thread { + try { + var line: String? + while (stderrReader.readLine().also { line = it } != null) { + android.util.Log.w("SSHD", line ?: "") + } + } catch (_: Exception) {} + }.start() + + val exitCode = sshdProcess!!.waitFor() + + if (!isRunning) break // Intentional stop + + restartCount++ + if (restartCount <= maxRestarts) { + updateNotification("SSH exited ($exitCode), restarting...") + Thread.sleep(2000L * restartCount) + } else { + isRunning = false + updateNotification("SSH stopped (exit $exitCode)") + stopSelf() + } + } } catch (e: Exception) { isRunning = false updateNotification("SSH error: ${e.message?.take(50)}") @@ -175,6 +195,24 @@ class SshForegroundService : Service() { } } + private fun getSystemDnsContent(): String { + try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + if (cm != null) { + val network = cm.activeNetwork + if (network != null) { + val linkProps = cm.getLinkProperties(network) + val dnsServers = linkProps?.dnsServers + if (dnsServers != null && dnsServers.isNotEmpty()) { + val lines = dnsServers.joinToString("\n") { "nameserver ${it.hostAddress}" } + return "$lines\nnameserver 8.8.8.8\n" + } + } + } + } catch (_: Exception) {} + return "nameserver 8.8.8.8\nnameserver 8.8.4.4\n" + } + private fun acquireWakeLock() { releaseWakeLock() val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager diff --git a/flutter_app/android/app/src/main/res/xml/backup_rules.xml b/flutter_app/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..076be9f --- /dev/null +++ b/flutter_app/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/flutter_app/android/app/src/main/res/xml/data_extraction_rules.xml b/flutter_app/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..c449f35 --- /dev/null +++ b/flutter_app/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/flutter_app/android/app/src/main/res/xml/usb_device_filter.xml b/flutter_app/android/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..d7ebda3 --- /dev/null +++ b/flutter_app/android/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,4 @@ + + + + diff --git a/flutter_app/lib/constants.dart b/flutter_app/lib/constants.dart index 56c5c9e..3928574 100644 --- a/flutter_app/lib/constants.dart +++ b/flutter_app/lib/constants.dart @@ -1,6 +1,6 @@ class AppConstants { static const String appName = 'OpenClaw'; - static const String version = '1.8.2'; + static const String version = '1.8.4'; static const String packageName = 'com.nxg.openclawproot'; /// Matches ANSI escape sequences (e.g. color codes in terminal output). @@ -11,6 +11,9 @@ class AppConstants { static const String githubUrl = 'https://github.com/mithun50/openclaw-termux'; static const String license = 'MIT'; + static const String githubApiLatestRelease = + 'https://api.github.com/repos/mithun50/openclaw-termux/releases/latest'; + // NextGenX static const String orgName = 'NextGenX'; static const String orgEmail = 'nxgextra@gmail.com'; @@ -48,7 +51,7 @@ class AppConstants { } static const int healthCheckIntervalMs = 5000; - static const int maxAutoRestarts = 3; + static const int maxAutoRestarts = 5; // Node constants static const int wsReconnectBaseMs = 350; diff --git a/flutter_app/lib/providers/node_provider.dart b/flutter_app/lib/providers/node_provider.dart index 7d02a16..2848636 100644 --- a/flutter_app/lib/providers/node_provider.dart +++ b/flutter_app/lib/providers/node_provider.dart @@ -9,6 +9,7 @@ import '../services/capabilities/flash_capability.dart'; import '../services/capabilities/location_capability.dart'; import '../services/capabilities/screen_capability.dart'; import '../services/capabilities/sensor_capability.dart'; +import '../services/capabilities/serial_capability.dart'; import '../services/capabilities/vibration_capability.dart'; import '../services/native_bridge.dart'; import '../services/node_service.dart'; @@ -29,6 +30,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { final _locationCapability = LocationCapability(); final _screenCapability = ScreenCapability(); final _sensorCapability = SensorCapability(); + final _serialCapability = SerialCapability(); final _vibrationCapability = VibrationCapability(); NodeState get state => _state; @@ -74,8 +76,10 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { + _nodeService.setAppInForeground(true); _onAppResumed(); } else if (state == AppLifecycleState.paused) { + _nodeService.setAppInForeground(false); _onAppPaused(); } } @@ -156,6 +160,11 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { _sensorCapability.commands.map((c) => '${_sensorCapability.name}.$c').toList(), (cmd, params) => _sensorCapability.handleWithPermission(cmd, params), ); + _nodeService.registerCapability( + _serialCapability.name, + _serialCapability.commands.map((c) => '${_serialCapability.name}.$c').toList(), + (cmd, params) => _serialCapability.handleWithPermission(cmd, params), + ); } Future _init() async { @@ -211,6 +220,8 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { Permission.camera, Permission.location, Permission.sensors, + Permission.bluetoothConnect, + Permission.bluetoothScan, ].request(); } @@ -306,6 +317,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { _nodeService.dispose(); _cameraCapability.dispose(); _flashCapability.dispose(); + _serialCapability.dispose(); NativeBridge.stopNodeService(); super.dispose(); } diff --git a/flutter_app/lib/screens/dashboard_screen.dart b/flutter_app/lib/screens/dashboard_screen.dart index e2632f2..dc8d5c1 100644 --- a/flutter_app/lib/screens/dashboard_screen.dart +++ b/flutter_app/lib/screens/dashboard_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../constants.dart'; import '../providers/gateway_provider.dart'; @@ -64,18 +65,41 @@ class DashboardScreen extends StatelessWidget { ), Consumer( builder: (context, provider, _) { + final url = provider.state.dashboardUrl; + final token = url != null + ? RegExp(r'#token=([0-9a-f]+)').firstMatch(url)?.group(1) + : null; + final subtitle = provider.state.isRunning + ? (token != null + ? 'Token: ${token.substring(0, (token.length > 8 ? 8 : token.length))}...' + : 'Open OpenClaw dashboard in browser') + : 'Start gateway first'; return StatusCard( title: 'Web Dashboard', - subtitle: provider.state.isRunning - ? 'Open OpenClaw dashboard in browser' - : 'Start gateway first', + subtitle: subtitle, icon: Icons.dashboard, - trailing: const Icon(Icons.chevron_right), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (token != null) + IconButton( + icon: const Icon(Icons.copy, size: 18), + tooltip: 'Copy dashboard URL', + onPressed: () { + Clipboard.setData(ClipboardData(text: url!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Dashboard URL copied')), + ); + }, + ), + const Icon(Icons.chevron_right), + ], + ), onTap: provider.state.isRunning ? () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => WebDashboardScreen( - url: provider.state.dashboardUrl, + url: url, ), ), ) diff --git a/flutter_app/lib/screens/node_screen.dart b/flutter_app/lib/screens/node_screen.dart index 44fca9c..f9b012d 100644 --- a/flutter_app/lib/screens/node_screen.dart +++ b/flutter_app/lib/screens/node_screen.dart @@ -183,8 +183,9 @@ class _NodeScreenState extends State { _capabilityTile( theme, 'Canvas', - 'Navigate and interact with web pages', + 'Not available on mobile', Icons.web, + available: false, ), _capabilityTile( theme, @@ -216,6 +217,12 @@ class _NodeScreenState extends State { 'Read accelerometer, gyroscope, magnetometer, barometer', Icons.sensors, ), + _capabilityTile( + theme, + 'Serial', + 'Bluetooth and USB serial communication', + Icons.usb, + ), const SizedBox(height: 16), // Device Info @@ -285,17 +292,24 @@ class _NodeScreenState extends State { } Widget _capabilityTile( - ThemeData theme, String title, String subtitle, IconData icon) { + ThemeData theme, String title, String subtitle, IconData icon, + {bool available = true}) { return Card( child: ListTile( leading: Icon(icon, color: theme.colorScheme.onSurfaceVariant), title: Text(title), subtitle: Text(subtitle), - trailing: const Icon( - Icons.check_circle, - color: AppColors.statusGreen, - size: 20, - ), + trailing: available + ? const Icon( + Icons.check_circle, + color: AppColors.statusGreen, + size: 20, + ) + : const Icon( + Icons.block, + color: AppColors.statusAmber, + size: 20, + ), ), ); } diff --git a/flutter_app/lib/screens/settings_screen.dart b/flutter_app/lib/screens/settings_screen.dart index a256c99..3aad664 100644 --- a/flutter_app/lib/screens/settings_screen.dart +++ b/flutter_app/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ import '../constants.dart'; import '../providers/node_provider.dart'; import '../services/native_bridge.dart'; import '../services/preferences_service.dart'; +import '../services/update_service.dart'; import 'node_screen.dart'; import 'setup_wizard_screen.dart'; @@ -32,6 +33,7 @@ class _SettingsScreenState extends State { bool _brewInstalled = false; bool _sshInstalled = false; bool _storageGranted = false; + bool _checkingUpdate = false; @override void initState() { @@ -117,11 +119,11 @@ class _SettingsScreenState extends State { ListTile( title: const Text('Setup Storage'), subtitle: Text(_storageGranted - ? 'Granted — /sdcard accessible in proot' + ? 'Granted — proot can access /sdcard. Revoke if not needed.' : 'Allow access to shared storage'), leading: const Icon(Icons.sd_storage), trailing: _storageGranted - ? const Icon(Icons.check_circle, color: AppColors.statusGreen) + ? const Icon(Icons.warning_amber, color: AppColors.statusAmber) : const Icon(Icons.warning, color: AppColors.statusAmber), onTap: () async { await NativeBridge.requestStoragePermission(); @@ -247,6 +249,18 @@ class _SettingsScreenState extends State { leading: Icon(Icons.info_outline), isThreeLine: true, ), + ListTile( + title: const Text('Check for Updates'), + subtitle: const Text('Check GitHub for a newer release'), + leading: _checkingUpdate + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update), + onTap: _checkingUpdate ? null : _checkForUpdates, + ), const ListTile( title: Text('Developer'), subtitle: Text(AppConstants.authorName), @@ -347,6 +361,10 @@ class _SettingsScreenState extends State { 'dashboardUrl': _prefs.dashboardUrl, 'autoStart': _prefs.autoStartGateway, 'nodeEnabled': _prefs.nodeEnabled, + 'nodeDeviceToken': _prefs.nodeDeviceToken, + 'nodeGatewayHost': _prefs.nodeGatewayHost, + 'nodeGatewayPort': _prefs.nodeGatewayPort, + 'nodeGatewayToken': _prefs.nodeGatewayToken, }; final path = await _getSnapshotPath(); @@ -397,6 +415,18 @@ class _SettingsScreenState extends State { if (snapshot['nodeEnabled'] != null) { _prefs.nodeEnabled = snapshot['nodeEnabled'] as bool; } + if (snapshot['nodeDeviceToken'] != null) { + _prefs.nodeDeviceToken = snapshot['nodeDeviceToken'] as String; + } + if (snapshot['nodeGatewayHost'] != null) { + _prefs.nodeGatewayHost = snapshot['nodeGatewayHost'] as String; + } + if (snapshot['nodeGatewayPort'] != null) { + _prefs.nodeGatewayPort = snapshot['nodeGatewayPort'] as int; + } + if (snapshot['nodeGatewayToken'] != null) { + _prefs.nodeGatewayToken = snapshot['nodeGatewayToken'] as String; + } // Refresh UI await _loadSettings(); @@ -413,6 +443,54 @@ class _SettingsScreenState extends State { } } + Future _checkForUpdates() async { + setState(() => _checkingUpdate = true); + try { + final result = await UpdateService.check(); + if (!mounted) return; + if (result.available) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Update Available'), + content: Text( + 'A new version is available.\n\n' + 'Current: ${AppConstants.version}\n' + 'Latest: ${result.latest}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Later'), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + launchUrl( + Uri.parse(result.url), + mode: LaunchMode.externalApplication, + ); + }, + child: const Text('Download'), + ), + ], + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("You're on the latest version")), + ); + } + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not check for updates')), + ); + } finally { + if (mounted) setState(() => _checkingUpdate = false); + } + } + Widget _sectionHeader(ThemeData theme, String title) { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), diff --git a/flutter_app/lib/screens/splash_screen.dart b/flutter_app/lib/screens/splash_screen.dart index f098f8d..48771f4 100644 --- a/flutter_app/lib/screens/splash_screen.dart +++ b/flutter_app/lib/screens/splash_screen.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -73,6 +74,39 @@ class _SplashScreenState extends State final prefs = PreferencesService(); await prefs.init(); + // Auto-export snapshot when app version changes (#55) + try { + final oldVersion = prefs.lastAppVersion; + if (oldVersion != null && oldVersion != AppConstants.version) { + final hasPermission = await NativeBridge.hasStoragePermission(); + if (hasPermission) { + final sdcard = await NativeBridge.getExternalStoragePath(); + final downloadDir = Directory('$sdcard/Download'); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + final snapshotPath = '$sdcard/Download/openclaw-snapshot-$oldVersion.json'; + final openclawJson = await NativeBridge.readRootfsFile('root/.openclaw/openclaw.json'); + final snapshot = { + 'version': oldVersion, + 'timestamp': DateTime.now().toIso8601String(), + 'openclawConfig': openclawJson, + 'dashboardUrl': prefs.dashboardUrl, + 'autoStart': prefs.autoStartGateway, + 'nodeEnabled': prefs.nodeEnabled, + 'nodeDeviceToken': prefs.nodeDeviceToken, + 'nodeGatewayHost': prefs.nodeGatewayHost, + 'nodeGatewayPort': prefs.nodeGatewayPort, + 'nodeGatewayToken': prefs.nodeGatewayToken, + }; + await File(snapshotPath).writeAsString( + const JsonEncoder.withIndent(' ').convert(snapshot), + ); + } + } + prefs.lastAppVersion = AppConstants.version; + } catch (_) {} + bool setupComplete; try { setupComplete = await NativeBridge.isBootstrapComplete(); diff --git a/flutter_app/lib/services/bootstrap_service.dart b/flutter_app/lib/services/bootstrap_service.dart index 2b1c738..9399cbb 100644 --- a/flutter_app/lib/services/bootstrap_service.dart +++ b/flutter_app/lib/services/bootstrap_service.dart @@ -184,7 +184,7 @@ class BootstrapService { ); await NativeBridge.runInProot( 'apt-get install -y --no-install-recommends ' - 'ca-certificates git python3 make g++', + 'ca-certificates git python3 make g++ curl wget', ); // Git config (.gitconfig) is written by installBionicBypass() on the diff --git a/flutter_app/lib/services/capabilities/serial_capability.dart b/flutter_app/lib/services/capabilities/serial_capability.dart new file mode 100644 index 0000000..bc26127 --- /dev/null +++ b/flutter_app/lib/services/capabilities/serial_capability.dart @@ -0,0 +1,389 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:usb_serial/usb_serial.dart'; +import '../../models/node_frame.dart'; +import 'capability_handler.dart'; + +class SerialCapability extends CapabilityHandler { + /// Nordic UART Service UUIDs + static const _nusServiceUuid = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; + static const _nusTxCharUuid = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; + static const _nusRxCharUuid = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; + + final Map _connections = {}; + + @override + String get name => 'serial'; + + @override + List get commands => ['list', 'connect', 'disconnect', 'write', 'read']; + + @override + List get requiredPermissions => [ + Permission.bluetoothConnect, + Permission.bluetoothScan, + ]; + + @override + Future checkPermission() async { + return await Permission.bluetoothConnect.isGranted && + await Permission.bluetoothScan.isGranted; + } + + @override + Future requestPermission() async { + final statuses = await [ + Permission.bluetoothConnect, + Permission.bluetoothScan, + ].request(); + return statuses.values.every((s) => s.isGranted); + } + + @override + Future handle(String command, Map params) async { + switch (command) { + case 'serial.list': + return _list(); + case 'serial.connect': + return _connect(params); + case 'serial.disconnect': + return _disconnect(params); + case 'serial.write': + return _write(params); + case 'serial.read': + return _read(params); + default: + return NodeFrame.response('', error: { + 'code': 'UNKNOWN_COMMAND', + 'message': 'Unknown serial command: $command', + }); + } + } + + Future _list() async { + final devices = >[]; + + // List USB devices + try { + final usbDevices = await UsbSerial.listDevices(); + for (final d in usbDevices) { + devices.add({ + 'id': 'usb:${d.deviceId}', + 'type': 'usb', + 'name': d.productName ?? 'USB Device', + 'vendorId': d.vid, + 'productId': d.pid, + }); + } + } catch (_) {} + + // List BLE devices (quick scan) + try { + final bleDevices = []; + final subscription = FlutterBluePlus.scanResults.listen((results) { + for (final r in results) { + if (!bleDevices.any((d) => d.remoteId == r.device.remoteId)) { + bleDevices.add(r.device); + } + } + }); + await FlutterBluePlus.startScan(timeout: const Duration(seconds: 3)); + await Future.delayed(const Duration(seconds: 3)); + subscription.cancel(); + await FlutterBluePlus.stopScan(); + + for (final d in bleDevices) { + devices.add({ + 'id': 'ble:${d.remoteId}', + 'type': 'ble', + 'name': d.platformName.isNotEmpty ? d.platformName : 'BLE Device', + }); + } + } catch (_) {} + + return NodeFrame.response('', payload: {'devices': devices}); + } + + Future _connect(Map params) async { + final deviceId = params['deviceId'] as String?; + if (deviceId == null) { + return NodeFrame.response('', error: { + 'code': 'MISSING_PARAM', + 'message': 'deviceId is required', + }); + } + + if (_connections.containsKey(deviceId)) { + return NodeFrame.response('', payload: { + 'status': 'already_connected', + 'deviceId': deviceId, + }); + } + + try { + if (deviceId.startsWith('usb:')) { + final usbId = int.tryParse(deviceId.substring(4)); + final usbDevices = await UsbSerial.listDevices(); + final device = usbDevices.firstWhere( + (d) => d.deviceId == usbId, + orElse: () => throw Exception('USB device not found'), + ); + final port = await device.create(); + if (port == null) throw Exception('Failed to create USB port'); + final opened = await port.open(); + if (!opened) throw Exception('Failed to open USB port'); + + final baudRate = params['baudRate'] as int? ?? 115200; + await port.setDTR(true); + await port.setRTS(true); + await port.setPortParameters( + baudRate, + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + _connections[deviceId] = _SerialConnection.usb(port); + return NodeFrame.response('', payload: { + 'status': 'connected', + 'deviceId': deviceId, + 'type': 'usb', + 'baudRate': baudRate, + }); + } else if (deviceId.startsWith('ble:')) { + final remoteId = deviceId.substring(4); + final device = BluetoothDevice.fromId(remoteId); + await device.connect(timeout: const Duration(seconds: 10)); + final services = await device.discoverServices(); + + BluetoothCharacteristic? txChar; + BluetoothCharacteristic? rxChar; + + for (final service in services) { + if (service.uuid.toString().toLowerCase() == _nusServiceUuid) { + for (final c in service.characteristics) { + final uuid = c.uuid.toString().toLowerCase(); + if (uuid == _nusTxCharUuid) txChar = c; + if (uuid == _nusRxCharUuid) rxChar = c; + } + } + } + + if (txChar != null) { + await txChar.setNotifyValue(true); + } + + _connections[deviceId] = _SerialConnection.ble(device, txChar, rxChar); + return NodeFrame.response('', payload: { + 'status': 'connected', + 'deviceId': deviceId, + 'type': 'ble', + 'hasNus': txChar != null && rxChar != null, + }); + } + + return NodeFrame.response('', error: { + 'code': 'INVALID_DEVICE_ID', + 'message': 'deviceId must start with usb: or ble:', + }); + } catch (e) { + return NodeFrame.response('', error: { + 'code': 'CONNECT_ERROR', + 'message': '$e', + }); + } + } + + Future _disconnect(Map params) async { + final deviceId = params['deviceId'] as String?; + if (deviceId == null) { + return NodeFrame.response('', error: { + 'code': 'MISSING_PARAM', + 'message': 'deviceId is required', + }); + } + + final conn = _connections.remove(deviceId); + if (conn == null) { + return NodeFrame.response('', payload: { + 'status': 'not_connected', + 'deviceId': deviceId, + }); + } + + try { + await conn.close(); + } catch (_) {} + + return NodeFrame.response('', payload: { + 'status': 'disconnected', + 'deviceId': deviceId, + }); + } + + Future _write(Map params) async { + final deviceId = params['deviceId'] as String?; + final data = params['data'] as String?; + if (deviceId == null || data == null) { + return NodeFrame.response('', error: { + 'code': 'MISSING_PARAM', + 'message': 'deviceId and data are required', + }); + } + + final conn = _connections[deviceId]; + if (conn == null) { + return NodeFrame.response('', error: { + 'code': 'NOT_CONNECTED', + 'message': 'Device not connected: $deviceId', + }); + } + + try { + final bytes = utf8.encode(data); + await conn.write(Uint8List.fromList(bytes)); + return NodeFrame.response('', payload: { + 'status': 'written', + 'deviceId': deviceId, + 'bytesWritten': bytes.length, + }); + } catch (e) { + return NodeFrame.response('', error: { + 'code': 'WRITE_ERROR', + 'message': '$e', + }); + } + } + + Future _read(Map params) async { + final deviceId = params['deviceId'] as String?; + if (deviceId == null) { + return NodeFrame.response('', error: { + 'code': 'MISSING_PARAM', + 'message': 'deviceId is required', + }); + } + + final conn = _connections[deviceId]; + if (conn == null) { + return NodeFrame.response('', error: { + 'code': 'NOT_CONNECTED', + 'message': 'Device not connected: $deviceId', + }); + } + + try { + final timeoutMs = params['timeoutMs'] as int? ?? 2000; + final data = await conn.read(Duration(milliseconds: timeoutMs)); + return NodeFrame.response('', payload: { + 'deviceId': deviceId, + 'data': data != null ? utf8.decode(data, allowMalformed: true) : null, + 'bytesRead': data?.length ?? 0, + }); + } catch (e) { + return NodeFrame.response('', error: { + 'code': 'READ_ERROR', + 'message': '$e', + }); + } + } + + void dispose() { + for (final conn in _connections.values) { + try { conn.close(); } catch (_) {} + } + _connections.clear(); + } +} + +class _SerialConnection { + final UsbPort? usbPort; + final BluetoothDevice? bleDevice; + final BluetoothCharacteristic? bleTxChar; + final BluetoothCharacteristic? bleRxChar; + final List _bleBuffer = []; + StreamSubscription? _bleSubscription; + + _SerialConnection.usb(this.usbPort) + : bleDevice = null, + bleTxChar = null, + bleRxChar = null; + + _SerialConnection.ble(this.bleDevice, this.bleTxChar, this.bleRxChar) + : usbPort = null { + if (bleTxChar != null) { + _bleSubscription = bleTxChar!.onValueReceived.listen((data) { + _bleBuffer.addAll(data); + }); + } + } + + Future write(Uint8List data) async { + if (usbPort != null) { + await usbPort!.write(data); + } else if (bleRxChar != null) { + // BLE has MTU limits, send in chunks + const mtu = 20; + for (var i = 0; i < data.length; i += mtu) { + final end = (i + mtu < data.length) ? i + mtu : data.length; + await bleRxChar!.write(data.sublist(i, end), withoutResponse: true); + } + } else { + throw Exception('No writable channel'); + } + } + + Future read(Duration timeout) async { + if (usbPort != null) { + // Read from USB input stream with timeout + final completer = Completer(); + StreamSubscription? sub; + Timer? timer; + sub = usbPort!.inputStream?.listen((data) { + timer?.cancel(); + sub?.cancel(); + completer.complete(Uint8List.fromList(data)); + }); + timer = Timer(timeout, () { + sub?.cancel(); + completer.complete(null); + }); + return completer.future; + } else if (bleTxChar != null) { + // Return buffered BLE data or wait + if (_bleBuffer.isNotEmpty) { + final data = Uint8List.fromList(_bleBuffer); + _bleBuffer.clear(); + return data; + } + // Wait for data with timeout + final completer = Completer(); + late StreamSubscription sub; + Timer? timer; + sub = bleTxChar!.onValueReceived.listen((data) { + timer?.cancel(); + sub.cancel(); + completer.complete(Uint8List.fromList(data)); + }); + timer = Timer(timeout, () { + sub.cancel(); + completer.complete(null); + }); + return completer.future; + } + return null; + } + + Future close() async { + _bleSubscription?.cancel(); + if (usbPort != null) { + await usbPort!.close(); + } + if (bleDevice != null) { + await bleDevice!.disconnect(); + } + } +} diff --git a/flutter_app/lib/services/gateway_service.dart b/flutter_app/lib/services/gateway_service.dart index 95a10d9..a66adcc 100644 --- a/flutter_app/lib/services/gateway_service.dart +++ b/flutter_app/lib/services/gateway_service.dart @@ -9,9 +9,12 @@ import 'preferences_service.dart'; class GatewayService { Timer? _healthTimer; + Timer? _initialDelayTimer; StreamSubscription? _logSubscription; final _stateController = StreamController.broadcast(); GatewayState _state = const GatewayState(); + DateTime? _startingAt; + bool _startInProgress = false; static final _tokenUrlRegex = RegExp(r'https?://(?:localhost|127\.0\.0\.1):18789/#token=[0-9a-f]+'); static final _boxDrawing = RegExp(r'[│┤├┬┴┼╮╯╰╭─╌╴╶┌┐└┘◇◆]+'); @@ -24,6 +27,8 @@ class GatewayService { .replaceAll(RegExp(r'\s+'), ''); } + static String _ts(String msg) => '${DateTime.now().toUtc().toIso8601String()} $msg'; + Stream get stateStream => _stateController.stream; GatewayState get state => _state; @@ -66,17 +71,18 @@ class GatewayService { // Write allowCommands config so the next gateway restart picks it up, // and in case the running gateway supports config hot-reload. await _writeNodeAllowConfig(); + _startingAt = DateTime.now(); _updateState(_state.copyWith( status: GatewayStatus.starting, dashboardUrl: savedUrl, - logs: [..._state.logs, '[INFO] Gateway process detected, reconnecting...'], + logs: [..._state.logs, _ts('[INFO] Gateway process detected, reconnecting...')], )); _subscribeLogs(); _startHealthCheck(); } else if (prefs.autoStartGateway) { _updateState(_state.copyWith( - logs: [..._state.logs, '[INFO] Auto-starting gateway...'], + logs: [..._state.logs, _ts('[INFO] Auto-starting gateway...')], )); await start(); } @@ -96,6 +102,7 @@ class GatewayService { dashboardUrl = urlMatch.group(0); final prefs = PreferencesService(); prefs.init().then((_) => prefs.dashboardUrl = dashboardUrl); + NativeBridge.showUrlNotification(dashboardUrl!, title: 'Dashboard Ready'); } _updateState(_state.copyWith(logs: logs, dashboardUrl: dashboardUrl)); }); @@ -113,6 +120,7 @@ class GatewayService { 'screen.record', 'sensor.read', 'sensor.list', 'haptic.vibrate', + 'serial.list', 'serial.connect', 'serial.disconnect', 'serial.write', 'serial.read', ]; // Use a Node.js one-liner to safely merge into existing openclaw.json // without clobbering other settings (API keys, onboarding config, etc.) @@ -128,13 +136,40 @@ c.gateway.nodes.denyCommands = []; c.gateway.nodes.allowCommands = $allowJson; fs.writeFileSync(p, JSON.stringify(c, null, 2)); '''; + var prootOk = false; try { await NativeBridge.runInProot( 'node -e ${_shellEscape(script)}', timeout: 15, ); - } catch (_) { - // Non-fatal: gateway may still work with default policy + prootOk = true; + } catch (_) {} + + // Direct file I/O fallback (#56): if proot/node isn't ready, write the + // config directly on the Android filesystem so the gateway still picks + // up allowCommands on next start. + if (!prootOk) { + try { + final filesDir = await NativeBridge.getFilesDir(); + final configFile = File('$filesDir/rootfs/ubuntu/root/.openclaw/openclaw.json'); + Map config = {}; + if (configFile.existsSync()) { + try { + config = Map.from( + jsonDecode(configFile.readAsStringSync()) as Map); + } catch (_) {} + } + config.putIfAbsent('gateway', () => {}); + final gw = config['gateway'] as Map; + gw.putIfAbsent('nodes', () => {}); + final nodes = gw['nodes'] as Map; + nodes['denyCommands'] = []; + nodes['allowCommands'] = allowCommands; + configFile.parent.createSync(recursive: true); + configFile.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(config), + ); + } catch (_) {} } } @@ -144,6 +179,10 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } Future start() async { + // Prevent concurrent start() calls from racing + if (_startInProgress) return; + _startInProgress = true; + final prefs = PreferencesService(); await prefs.init(); final savedUrl = prefs.dashboardUrl; @@ -151,7 +190,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); _updateState(_state.copyWith( status: GatewayStatus.starting, clearError: true, - logs: [..._state.logs, '[INFO] Starting gateway...'], + logs: [..._state.logs, _ts('[INFO] Starting gateway...')], dashboardUrl: savedUrl, )); @@ -177,6 +216,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } catch (_) {} await _writeNodeAllowConfig(); + _startingAt = DateTime.now(); await NativeBridge.startGateway(); _subscribeLogs(); _startHealthCheck(); @@ -184,20 +224,23 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); _updateState(_state.copyWith( status: GatewayStatus.error, errorMessage: 'Failed to start: $e', - logs: [..._state.logs, '[ERROR] Failed to start: $e'], + logs: [..._state.logs, _ts('[ERROR] Failed to start: $e')], )); + } finally { + _startInProgress = false; } } Future stop() async { - _healthTimer?.cancel(); + _cancelAllTimers(); _logSubscription?.cancel(); + _startingAt = null; try { await NativeBridge.stopGateway(); _updateState(GatewayState( status: GatewayStatus.stopped, - logs: [..._state.logs, '[INFO] Gateway stopped'], + logs: [..._state.logs, _ts('[INFO] Gateway stopped')], )); } catch (e) { _updateState(_state.copyWith( @@ -207,12 +250,27 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } - void _startHealthCheck() { + /// Cancel both the initial delay timer and periodic health timer. + void _cancelAllTimers() { + _initialDelayTimer?.cancel(); + _initialDelayTimer = null; _healthTimer?.cancel(); - _healthTimer = Timer.periodic( - const Duration(milliseconds: AppConstants.healthCheckIntervalMs), - (_) => _checkHealth(), - ); + _healthTimer = null; + } + + void _startHealthCheck() { + _cancelAllTimers(); + // Delay the first health check by 30s — Node.js inside proot needs time to start. + // Use a Timer (not Future.delayed) so it can be cancelled on stop(). + _initialDelayTimer = Timer(const Duration(seconds: 30), () { + _initialDelayTimer = null; + if (_state.status == GatewayStatus.stopped) return; + _checkHealth(); + _healthTimer = Timer.periodic( + const Duration(milliseconds: AppConstants.healthCheckIntervalMs), + (_) => _checkHealth(), + ); + }); } Future _checkHealth() async { @@ -225,18 +283,28 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); _updateState(_state.copyWith( status: GatewayStatus.running, startedAt: DateTime.now(), - logs: [..._state.logs, '[INFO] Gateway is healthy'], + logs: [..._state.logs, _ts('[INFO] Gateway is healthy')], )); } } catch (_) { // Still starting or temporarily unreachable final isRunning = await NativeBridge.isGatewayRunning(); if (!isRunning && _state.status != GatewayStatus.stopped) { + // Grace period: if we're still within 120s of startup, don't declare dead. + // proot + Node.js can take a long time on first boot. + if (_startingAt != null && + _state.status == GatewayStatus.starting && + DateTime.now().difference(_startingAt!).inSeconds < 120) { + _updateState(_state.copyWith( + logs: [..._state.logs, _ts('[INFO] Starting, waiting for gateway...')], + )); + return; + } _updateState(_state.copyWith( status: GatewayStatus.stopped, - logs: [..._state.logs, '[WARN] Gateway process not running'], + logs: [..._state.logs, _ts('[WARN] Gateway process not running')], )); - _healthTimer?.cancel(); + _cancelAllTimers(); } } } @@ -253,7 +321,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } void dispose() { - _healthTimer?.cancel(); + _cancelAllTimers(); _logSubscription?.cancel(); _stateController.close(); } diff --git a/flutter_app/lib/services/native_bridge.dart b/flutter_app/lib/services/native_bridge.dart index 7f0cbcc..70b0cbe 100644 --- a/flutter_app/lib/services/native_bridge.dart +++ b/flutter_app/lib/services/native_bridge.dart @@ -180,6 +180,10 @@ class NativeBridge { return List.from(result); } + static Future bringToForeground() async { + return await _channel.invokeMethod('bringToForeground'); + } + static Future setRootPassword(String password) async { return await _channel.invokeMethod('setRootPassword', {'password': password}); } diff --git a/flutter_app/lib/services/node_service.dart b/flutter_app/lib/services/node_service.dart index 8503ead..95c9256 100644 --- a/flutter_app/lib/services/node_service.dart +++ b/flutter_app/lib/services/node_service.dart @@ -18,6 +18,11 @@ class NodeService { final Map Function(String, Map)> _capabilityHandlers = {}; String? _gatewayAuthToken; + bool _isAppInForeground = true; + + void setAppInForeground(bool value) { + _isAppInForeground = value; + } Stream get stateStream => _stateController.stream; NodeState get state => _state; @@ -258,11 +263,16 @@ class NodeService { )); _log('[NODE] Paired and connected'); - // Send capabilities advertisement + // Send capabilities advertisement — include both 'capabilities' (legacy) + // and 'commands' (matching the connect frame format) so the gateway can + // discover node commands regardless of which field it checks (#56). final capabilities = _capabilityHandlers.keys.toList(); + final caps = capabilities.map((c) => c.split('.').first).toSet().toList(); _ws.send(NodeFrame.event('node.capabilities', { 'deviceId': _identity.deviceId, 'capabilities': capabilities, + 'commands': capabilities, + 'caps': caps, })); } @@ -360,6 +370,29 @@ class NodeService { } catch (_) {} } + // Commands that require Activity in foreground (camera, screen, sensor, flash, location) + const foregroundCommands = ['camera', 'screen', 'sensor', 'flash', 'location']; + final commandPrefix = command.split('.').first; + if (foregroundCommands.contains(commandPrefix) && !_isAppInForeground) { + _log('[NODE] App backgrounded, bringing to foreground for $command'); + try { + await NativeBridge.bringToForeground(); + await Future.delayed(const Duration(milliseconds: 800)); + } catch (e) { + _log('[NODE] Failed to bring app to foreground: $e'); + _ws.sendRequest(NodeFrame.request('node.invoke.result', { + 'id': requestId, + 'nodeId': nodeId, + 'ok': false, + 'error': { + 'code': 'APP_BACKGROUNDED', + 'message': 'Cannot bring app to foreground for $command', + }, + })); + return; + } + } + final handler = _capabilityHandlers[command]; if (handler == null) { _log('[NODE] Unknown command: $command'); diff --git a/flutter_app/lib/services/preferences_service.dart b/flutter_app/lib/services/preferences_service.dart index 44c40b3..20418a4 100644 --- a/flutter_app/lib/services/preferences_service.dart +++ b/flutter_app/lib/services/preferences_service.dart @@ -11,6 +11,7 @@ class PreferencesService { static const _keyNodeGatewayPort = 'node_gateway_port'; static const _keyNodePublicKey = 'node_ed25519_public'; static const _keyNodeGatewayToken = 'node_gateway_token'; + static const _keyLastAppVersion = 'last_app_version'; late SharedPreferences _prefs; @@ -68,6 +69,15 @@ class PreferencesService { } } + String? get lastAppVersion => _prefs.getString(_keyLastAppVersion); + set lastAppVersion(String? value) { + if (value != null) { + _prefs.setString(_keyLastAppVersion, value); + } else { + _prefs.remove(_keyLastAppVersion); + } + } + int? get nodeGatewayPort { final val = _prefs.getInt(_keyNodeGatewayPort); return val; diff --git a/flutter_app/lib/services/provider_config_service.dart b/flutter_app/lib/services/provider_config_service.dart index 1d40411..93c1e7d 100644 --- a/flutter_app/lib/services/provider_config_service.dart +++ b/flutter_app/lib/services/provider_config_service.dart @@ -64,6 +64,7 @@ class ProviderConfigService { final providerJson = jsonEncode({ 'apiKey': apiKey, 'baseUrl': provider.baseUrl, + 'models': [model], }); final modelJson = jsonEncode(model); final providerIdJson = jsonEncode(provider.id); @@ -122,6 +123,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); ((config['models'] as Map)['providers'] as Map)[providerId] = { 'apiKey': apiKey, 'baseUrl': baseUrl, + 'models': [model], }; // Set active model diff --git a/flutter_app/lib/services/update_service.dart b/flutter_app/lib/services/update_service.dart new file mode 100644 index 0000000..ef11a06 --- /dev/null +++ b/flutter_app/lib/services/update_service.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../constants.dart'; + +class UpdateResult { + final String latest; + final String url; + final bool available; + + const UpdateResult({ + required this.latest, + required this.url, + required this.available, + }); +} + +class UpdateService { + static Future check() async { + final response = await http.get( + Uri.parse(AppConstants.githubApiLatestRelease), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + + if (response.statusCode != 200) { + throw Exception('GitHub API returned ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + final tagName = data['tag_name'] as String; + final htmlUrl = data['html_url'] as String; + + // Strip leading 'v' if present + final latest = tagName.startsWith('v') ? tagName.substring(1) : tagName; + final available = _isNewer(latest, AppConstants.version); + + return UpdateResult(latest: latest, url: htmlUrl, available: available); + } + + /// Returns true if [remote] is newer than [local] by semver comparison. + static bool _isNewer(String remote, String local) { + final r = remote.split('.').map(int.parse).toList(); + final l = local.split('.').map(int.parse).toList(); + for (var i = 0; i < 3; i++) { + final rv = i < r.length ? r[i] : 0; + final lv = i < l.length ? l[i] : 0; + if (rv > lv) return true; + if (rv < lv) return false; + } + return false; + } +} diff --git a/flutter_app/lib/widgets/terminal_toolbar.dart b/flutter_app/lib/widgets/terminal_toolbar.dart index bd23b4e..8675cef 100644 --- a/flutter_app/lib/widgets/terminal_toolbar.dart +++ b/flutter_app/lib/widgets/terminal_toolbar.dart @@ -176,6 +176,7 @@ class _TerminalToolbarState extends State { keyButton('CTRL', onTap: _toggleCtrl, active: _ctrlActive), keyButton('ALT', onTap: _toggleAlt, active: _altActive), keyButton('TAB', sendData: '\t'), + keyButton('ENTER', sendData: '\r'), const SizedBox(width: 4), arrowButton(Icons.arrow_upward, '\x1b[A'), arrowButton(Icons.arrow_downward, '\x1b[B'), diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 9ba5c4d..72e19c3 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -1,7 +1,7 @@ name: openclaw description: OpenClaw AI Gateway for Android - standalone, no Termux required. publish_to: 'none' -version: 1.8.3+14 +version: 1.8.4+15 environment: sdk: '>=3.2.0 <4.0.0' @@ -25,6 +25,8 @@ dependencies: uuid: ^4.2.0 camera: ^0.11.0 geolocator: ^12.0.0 + flutter_blue_plus: ^1.32.0 + usb_serial: ^0.5.1 dev_dependencies: flutter_test: