Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a89e595
feat: Serial over BT/USB (#21), log timestamps (#54), ADB backup (#55)
mithun50 Mar 5, 2026
a94c515
fix: Node capabilities not available to AI (#56)
mithun50 Mar 5, 2026
448d679
docs: Add #56 fix to changelog
mithun50 Mar 5, 2026
1201009
feat: Check for Updates menu option (#59)
mithun50 Mar 5, 2026
0c94229
docs: Add #59 update check to changelog
mithun50 Mar 5, 2026
720d89d
fix: Terminal ENTER key (#58), ADB backup (#55), background capabilit…
mithun50 Mar 6, 2026
5e8144f
fix: Remove invalid exclude rules from backup XML (#55)
mithun50 Mar 6, 2026
0140474
fix: SSHD crash with VPN (#61), DNS resolution (#60)
mithun50 Mar 6, 2026
67a3969
fix: Kill stale gateway processes before starting new instance (#60)
mithun50 Mar 6, 2026
1f41fe7
fix: Prevent deleteRecursively from following symlinks (data loss)
mithun50 Mar 6, 2026
5980af5
fix: Include models array in provider config (#60)
mithun50 Mar 6, 2026
ecd552f
revert: Remove pkill before gateway start
mithun50 Mar 6, 2026
60852aa
fix: Prevent duplicate gateway instances via port check (#60)
mithun50 Mar 6, 2026
3618c93
fix: Storage safety, gateway crash loop, auth UX, canvas status, miss…
mithun50 Mar 8, 2026
414dd17
fix: Prevent gateway crash loop from orphaned timers and race conditions
mithun50 Mar 8, 2026
33773de
fix: Detect actual process death instead of relying on stale boolean …
mithun50 Mar 8, 2026
6418a81
fix: Post emitLog to main thread — EventSink.success() requires UI th…
mithun50 Mar 8, 2026
8409df6
fix: isProcessAlive reports true while gateway thread is still settin…
mithun50 Mar 8, 2026
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 15 additions & 1 deletion flutter_app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:replace="android:maxSdkVersion" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />

<application
android:label="OpenClaw"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
android:extractNativeLibs="true">
android:extractNativeLibs="true"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">

<activity
android:name=".MainActivity"
Expand All @@ -44,6 +52,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
</activity>

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.nxg.openclawproot

import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.os.Build
import android.system.Os
import java.io.BufferedInputStream
import java.io.File
Expand Down Expand Up @@ -1212,8 +1215,31 @@ require('/root/.openclaw/proot-compat.js');
}
}

/**
* Read DNS servers from Android's active network. Falls back to
* Google DNS (8.8.8.8, 8.8.4.4) if system DNS is unavailable (#60).
*/
private fun getSystemDnsServers(): String {
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (cm != null) {
val network = cm.activeNetwork
if (network != null) {
val linkProps: LinkProperties? = cm.getLinkProperties(network)
val dnsServers = linkProps?.dnsServers
if (dnsServers != null && dnsServers.isNotEmpty()) {
val lines = dnsServers.joinToString("\n") { "nameserver ${it.hostAddress}" }
// Always append Google DNS as fallback
return "$lines\nnameserver 8.8.8.8\n"
}
}
}
} catch (_: Exception) {}
return "nameserver 8.8.8.8\nnameserver 8.8.4.4\n"
}

fun writeResolvConf() {
val content = "nameserver 8.8.8.8\nnameserver 8.8.4.4\n"
val content = getSystemDnsServers()
// Try context.filesDir first (Android-guaranteed), fall back to
// string-based configDir. Always call mkdirs() unconditionally. (#40)
try {
Expand All @@ -1230,10 +1256,8 @@ require('/root/.openclaw/proot-compat.js');
// even if the bind-mount fails or hasn't been set up yet (#40).
try {
val rootfsResolv = File(rootfsDir, "etc/resolv.conf")
if (!rootfsResolv.exists() || rootfsResolv.length() == 0L) {
rootfsResolv.parentFile?.mkdirs()
rootfsResolv.writeText(content)
}
rootfsResolv.parentFile?.mkdirs()
rootfsResolv.writeText(content)
} catch (_: Exception) {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@ class GatewayService : Service() {

private fun emitLog(message: String) {
try {
logSink?.success(message)
val ts = java.time.Instant.now().toString()
logSink?.success("$ts $message")
} catch (_: Exception) {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>("sensor") ?: "accelerometer"
Thread {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: isRunning is read cross-thread without @Volatile, so this visibility check may not work correctly. onDestroy() sets isRunning = false on the main thread, but this worker thread may never see the update due to JMM caching, causing unintended sshd restarts after an intentional stop. Add @Volatile to the isRunning declaration in the companion object.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/SshForegroundService.kt, line 171:

<comment>`isRunning` is read cross-thread without `@Volatile`, so this visibility check may not work correctly. `onDestroy()` sets `isRunning = false` on the main thread, but this worker thread may never see the update due to JMM caching, causing unintended sshd restarts after an intentional stop. Add `@Volatile` to the `isRunning` declaration in the companion object.</comment>

<file context>
@@ -114,52 +115,71 @@ class SshForegroundService : Service() {
+
+                    val exitCode = sshdProcess!!.waitFor()
+
+                    if (!isRunning) break // Intentional stop
+
+                    restartCount++
</file context>
Fix with Cubic


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)}")
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions flutter_app/android/app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- fullBackupContent rules for Android ≤11 -->
<full-backup-content>
<include domain="sharedpref" path="." />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Android SharedPreferences usage (Kotlin/Java) =="
rg -n -C2 --type=kt --type=java \
  'getSharedPreferences\(|EncryptedSharedPreferences|PreferenceManager\.getDefaultSharedPreferences' || true

echo
echo "== Dart persistence usage (shared_preferences / secure storage) =="
rg -n -C2 --type=dart \
  'SharedPreferences\.getInstance|shared_preferences|flutter_secure_storage' || true

echo
echo "== Potentially sensitive key names in Dart code =="
rg -n -C2 --type=dart \
  '(token|secret|password|api[_-]?key|auth)' flutter_app/lib || true

echo
echo "== Current backup XML resources =="
for f in $(fd 'backup_rules\.xml|data_extraction_rules\.xml' flutter_app/android/app/src/main/res/xml); do
  echo "--- $f ---"
  cat "$f"
done

Repository: mithun50/openclaw-termux

Length of output: 28711


Exclude private keys and auth tokens from backup rules — they are currently exposed.

Verification confirms this is a critical security issue. The app stores sensitive data in SharedPreferences that will be backed up:

  • Ed25519 private keys (_keyPrivate, _keyPublic) in node_identity_service.dart
  • Authentication tokens (node_device_token, node_gateway_token) in preferences_service.dart

The current rules (path=".") include all SharedPreferences files without exclusion. Add <exclude> entries to both backup_rules.xml and data_extraction_rules.xml for files containing these keys, or migrate private keys and tokens to Android's encrypted EncryptedSharedPreferences or Keystore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/android/app/src/main/res/xml/backup_rules.xml` at line 4, The
backup rules currently include all SharedPreferences (path=".") which exposes
sensitive items; update Android backup_rules.xml (and data_extraction_rules.xml)
to explicitly exclude the SharedPreferences files used by
node_identity_service.dart and preferences_service.dart that store _keyPrivate,
_keyPublic, node_device_token and node_gateway_token, or instead migrate those
stores to Android EncryptedSharedPreferences/Keystore; specifically add
<exclude> entries for the SharedPreferences filenames used by the code (or
switch code to use EncryptedSharedPreferences/Keystore when writing/reading keys
and tokens) so private keys and auth tokens are not backed up.

</full-backup-content>
10 changes: 10 additions & 0 deletions flutter_app/android/app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- dataExtractionRules for Android 12+ -->
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device />
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Overly permissive USB device filter — <usb-device /> with no attributes matches every USB device. This causes Android to prompt the user to open this app for any USB device attachment (keyboards, storage devices, cameras, etc.), not just serial adapters.

Consider restricting to common USB-serial adapter vendor/product IDs (FTDI, CP210x, CH340, PL2303) or at minimum filtering by device class. For example:

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/android/app/src/main/res/xml/usb_device_filter.xml, line 3:

<comment>Overly permissive USB device filter — `<usb-device />` with no attributes matches **every** USB device. This causes Android to prompt the user to open this app for any USB device attachment (keyboards, storage devices, cameras, etc.), not just serial adapters.

Consider restricting to common USB-serial adapter vendor/product IDs (FTDI, CP210x, CH340, PL2303) or at minimum filtering by device class. For example:</comment>

<file context>
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <usb-device />
+</resources>
</file context>
Fix with Cubic

</resources>
5 changes: 4 additions & 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.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).
Expand All @@ -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';
Expand Down
12 changes: 12 additions & 0 deletions flutter_app/lib/providers/node_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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<void> _init() async {
Expand Down Expand Up @@ -211,6 +220,8 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
Permission.camera,
Permission.location,
Permission.sensors,
Permission.bluetoothConnect,
Permission.bluetoothScan,
].request();
}

Expand Down Expand Up @@ -306,6 +317,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
_nodeService.dispose();
_cameraCapability.dispose();
_flashCapability.dispose();
_serialCapability.dispose();
NativeBridge.stopNodeService();
super.dispose();
}
Expand Down
6 changes: 6 additions & 0 deletions flutter_app/lib/screens/node_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ class _NodeScreenState extends State<NodeScreen> {
'Read accelerometer, gyroscope, magnetometer, barometer',
Icons.sensors,
),
_capabilityTile(
theme,
'Serial',
'Bluetooth and USB serial communication',
Icons.usb,
),
const SizedBox(height: 16),

// Device Info
Expand Down
Loading