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 =