Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
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
78 changes: 78 additions & 0 deletions flutter_app/lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -32,6 +33,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _brewInstalled = false;
bool _sshInstalled = false;
bool _storageGranted = false;
bool _checkingUpdate = false;

@override
void initState() {
Expand Down Expand Up @@ -247,6 +249,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
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),
Expand Down Expand Up @@ -347,6 +361,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
'dashboardUrl': _prefs.dashboardUrl,
'autoStart': _prefs.autoStartGateway,
'nodeEnabled': _prefs.nodeEnabled,
'nodeDeviceToken': _prefs.nodeDeviceToken,
'nodeGatewayHost': _prefs.nodeGatewayHost,
'nodeGatewayPort': _prefs.nodeGatewayPort,
'nodeGatewayToken': _prefs.nodeGatewayToken,
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.

P2: Authentication tokens (nodeDeviceToken, nodeGatewayToken) are exported in plaintext to the public Downloads directory, where other apps with storage permission can read them. Consider either warning the user that the export contains sensitive credentials, encrypting the snapshot file, or writing it to app-private storage (getApplicationDocumentsDirectory()) by default instead of only as a fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/lib/screens/settings_screen.dart, line 367:

<comment>Authentication tokens (`nodeDeviceToken`, `nodeGatewayToken`) are exported in plaintext to the public Downloads directory, where other apps with storage permission can read them. Consider either warning the user that the export contains sensitive credentials, encrypting the snapshot file, or writing it to app-private storage (`getApplicationDocumentsDirectory()`) by default instead of only as a fallback.</comment>

<file context>
@@ -361,6 +361,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
+        'nodeDeviceToken': _prefs.nodeDeviceToken,
+        'nodeGatewayHost': _prefs.nodeGatewayHost,
+        'nodeGatewayPort': _prefs.nodeGatewayPort,
+        'nodeGatewayToken': _prefs.nodeGatewayToken,
       };
 
</file context>
Fix with Cubic

};

final path = await _getSnapshotPath();
Expand Down Expand Up @@ -397,6 +415,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
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();
Expand All @@ -413,6 +443,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}

Future<void> _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,
);
Comment on lines +467 to +472
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

url_launcher package 6.2.0 launchUrl return value documentation

💡 Result:

In url_launcher 6.2.0, launchUrl returns a Future<bool>:

  • true if the URL was launched successfully.
  • Otherwise it returns false or throws a PlatformException (depending on the failure). [1]

Platform note (relevant to interpreting the boolean):

  • On Flutter Web, launchUrl always returns true for allowed schemes, because the browser doesn’t provide a way to know whether the new tab/window actually opened when using noopener. [2]

For 6.2.0 specifically, the package changelog also notes that the launchUrl documentation was updated around that release (and that 6.2.0 was later retracted due to incorrect types in new APIs). [3]

Sources: [1] [2] [3]


🏁 Script executed:

# First, verify the file and code exist at the specified location
if [ -f "flutter_app/lib/screens/settings_screen.dart" ]; then
  echo "File found. Reading lines 451-456:"
  sed -n '451,456p' flutter_app/lib/screens/settings_screen.dart
  echo ""
  echo "Context around lines 451-456 (440-465):"
  sed -n '440,465p' flutter_app/lib/screens/settings_screen.dart
else
  echo "File not found at flutter_app/lib/screens/settings_screen.dart"
fi

Repository: mithun50/openclaw-termux

Length of output: 1221


Handle launchUrl failure explicitly.

The download action currently ignores whether URL launch succeeds, so failures are silent. The launchUrl() function in url_launcher 6.2.0 returns Future<bool> where false indicates launch failure, and this return value should be checked to provide user feedback.

The proposed fix is correct: make the callback async, await the result, and show a SnackBar if the launch fails. The use of the mounted check is also appropriate for async callbacks in stateful widgets.

Proposed fix
               FilledButton(
-                onPressed: () {
+                onPressed: () async {
                   Navigator.pop(ctx);
-                  launchUrl(
+                  final opened = await launchUrl(
                     Uri.parse(result.url),
                     mode: LaunchMode.externalApplication,
                   );
+                  if (!opened && mounted) {
+                    ScaffoldMessenger.of(context).showSnackBar(
+                      const SnackBar(content: Text('Could not open release page')),
+                    );
+                  }
                 },
                 child: const Text('Download'),
               ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onPressed: () {
Navigator.pop(ctx);
launchUrl(
Uri.parse(result.url),
mode: LaunchMode.externalApplication,
);
onPressed: () async {
Navigator.pop(ctx);
final opened = await launchUrl(
Uri.parse(result.url),
mode: LaunchMode.externalApplication,
);
if (!opened && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open release page')),
);
}
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/settings_screen.dart` around lines 451 - 456, The
onPressed callback currently calls Navigator.pop(ctx) and launchUrl(...) without
checking the Future<bool> result; update the anonymous onPressed in
SettingsScreen to be async, await the bool returned by
launchUrl(Uri.parse(result.url), mode: LaunchMode.externalApplication), and if
it returns false show a SnackBar with an error message (ensure you check mounted
before showing the SnackBar); keep the existing Navigator.pop(ctx) call and only
perform the launch/feedback after popping.

},
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),
Expand Down
34 changes: 34 additions & 0 deletions flutter_app/lib/screens/splash_screen.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
Expand Down Expand Up @@ -73,6 +74,39 @@ class _SplashScreenState extends State<SplashScreen>
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,
};
Comment on lines +90 to +101
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: Security: Authentication tokens (nodeDeviceToken, nodeGatewayToken) and the full openclawConfig are written in plaintext to the shared Downloads folder on external storage. Any app with storage permission (or the user) can read these credentials. Either exclude sensitive fields from the snapshot, or encrypt the file, or write to app-private internal storage instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/lib/screens/splash_screen.dart, line 90:

<comment>Security: Authentication tokens (`nodeDeviceToken`, `nodeGatewayToken`) and the full `openclawConfig` are written in plaintext to the shared Downloads folder on external storage. Any app with storage permission (or the user) can read these credentials. Either exclude sensitive fields from the snapshot, or encrypt the file, or write to app-private internal storage instead.</comment>

<file context>
@@ -73,6 +74,39 @@ class _SplashScreenState extends State<SplashScreen>
+            }
+            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(),
</file context>
Suggested change
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,
};
final snapshot = {
'version': oldVersion,
'timestamp': DateTime.now().toIso8601String(),
'dashboardUrl': prefs.dashboardUrl,
'autoStart': prefs.autoStartGateway,
'nodeEnabled': prefs.nodeEnabled,
'nodeGatewayHost': prefs.nodeGatewayHost,
'nodeGatewayPort': prefs.nodeGatewayPort,
};
Fix with Cubic

Comment on lines +90 to +101
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not export sensitive tokens to public Downloads.

nodeDeviceToken and nodeGatewayToken are being written in plaintext to a broadly accessible path (Line 88), which can leak credentials.

🔐 Proposed fix (exclude secrets from snapshot)
             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,
+              // Intentionally omit credentials/tokens from public snapshot exports.
             };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
};
final snapshot = {
'version': oldVersion,
'timestamp': DateTime.now().toIso8601String(),
'openclawConfig': openclawJson,
'dashboardUrl': prefs.dashboardUrl,
'autoStart': prefs.autoStartGateway,
'nodeEnabled': prefs.nodeEnabled,
'nodeGatewayHost': prefs.nodeGatewayHost,
'nodeGatewayPort': prefs.nodeGatewayPort,
// Intentionally omit credentials/tokens from public snapshot exports.
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/splash_screen.dart` around lines 90 - 101, The
snapshot map in splash_screen.dart currently includes sensitive fields
nodeDeviceToken and nodeGatewayToken which are exported to the Downloads
snapshot; remove these keys (or replace them with a non-secret placeholder like
"[REDACTED]") before writing the snapshot to disk so secrets are never written
in plaintext. Locate the snapshot variable and ensure nodeDeviceToken and
nodeGatewayToken are excluded or masked prior to the code that saves/exports the
snapshot.

await File(snapshotPath).writeAsString(
const JsonEncoder.withIndent(' ').convert(snapshot),
);
}
}
prefs.lastAppVersion = AppConstants.version;
} catch (_) {}
Comment on lines +78 to +108
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure lastAppVersion is updated even when export fails.

Right now, an exception before Line 107 skips the version write, so snapshot export may retry forever across launches.

🛠️ Proposed fix (move version write to finally)
       // Auto-export snapshot when app version changes (`#55`)
-      try {
+      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 (_) {}
+      } catch (_) {
+      } finally {
+        prefs.lastAppVersion = AppConstants.version;
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 (_) {}
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),
);
}
}
} catch (_) {
} finally {
prefs.lastAppVersion = AppConstants.version;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/splash_screen.dart` around lines 78 - 108, The
snapshot export code can throw and currently swallows exceptions so
prefs.lastAppVersion is not updated; move the assignment prefs.lastAppVersion =
AppConstants.version out of the try body into a finally block (after the
try/catch) so it always runs regardless of errors during NativeBridge calls,
Directory/File operations, or JSON encoding; keep the existing catch (_) {}
behavior or replace it with more specific error handling if desired, but ensure
the finally always sets prefs.lastAppVersion to AppConstants.version.


bool setupComplete;
try {
setupComplete = await NativeBridge.isBootstrapComplete();
Expand Down
Loading