From 4a72909f8cde343e3440927119a45d6841057b83 Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:04:48 -0400 Subject: [PATCH 1/7] Add device file manager Adds a Tools menu entry that opens a file manager dialog for browsing device storage over ADB, uploading files via the OS file picker, downloading to the save location, and creating, renaming, and deleting entries. Remote paths are shell-quoted, and the ls parser handles both toybox and legacy toolbox output formats with unit tests. --- README.md | 1 + lib/models/device_file_model.dart | 32 ++ lib/providers/app_provider.dart | 129 +++++++ lib/screens/home_screen.dart | 16 + lib/services/adb_service.dart | 81 +++++ lib/services/device_file_parser.dart | 72 ++++ lib/widgets/file_manager_dialog.dart | 505 +++++++++++++++++++++++++++ test/device_file_parser_test.dart | 91 +++++ 8 files changed, 927 insertions(+) create mode 100644 lib/models/device_file_model.dart create mode 100644 lib/services/device_file_parser.dart create mode 100644 lib/widgets/file_manager_dialog.dart create mode 100644 test/device_file_parser_test.dart diff --git a/README.md b/README.md index ab83e8d..4c39979 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ with other Android tablets, phones, and TVs. - Curated application catalog with ARM64 and ARMv7 variants - Local APK installation - Screen mirroring, screenshots, and screen recording +- Device file manager for browsing, uploading, and downloading files - Installed-app, launcher, rotation, and developer-setting management - Windows, macOS, and Linux desktop builds diff --git a/lib/models/device_file_model.dart b/lib/models/device_file_model.dart new file mode 100644 index 0000000..01afaa4 --- /dev/null +++ b/lib/models/device_file_model.dart @@ -0,0 +1,32 @@ +class DeviceFileEntry { + final String name; + final String path; + final bool isDirectory; + final bool isSymlink; + final int? sizeBytes; + final String? modified; + + const DeviceFileEntry({ + required this.name, + required this.path, + required this.isDirectory, + this.isSymlink = false, + this.sizeBytes, + this.modified, + }); +} + +String formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + const units = ['KB', 'MB', 'GB', 'TB']; + double value = bytes / 1024; + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final text = value >= 100 + ? value.toStringAsFixed(0) + : value.toStringAsFixed(1); + return '$text ${units[unit]}'; +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 353fbd5..030295e 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -12,8 +12,10 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/adb_service.dart'; import '../services/config_service.dart'; +import '../services/device_file_parser.dart'; import '../services/release_asset_selector.dart'; import '../models/app_model.dart'; +import '../models/device_file_model.dart'; import '../models/device_model.dart'; import '../models/installed_app_model.dart'; import 'package:intl/intl.dart'; @@ -1291,6 +1293,133 @@ class AppProvider with ChangeNotifier { return {'success': success, 'fail': fail}; } + // --------------------------------------------------------------------------- + // File manager + // --------------------------------------------------------------------------- + + Future> listDeviceFiles(String path) async { + if (selectedDevice == null) return []; + return await _adbService.listDirectory(selectedDevice!.serial, path); + } + + /// Lets the user pick local files and pushes them into [remoteDir]. + /// Returns the number of files uploaded successfully, or null if the picker + /// was cancelled. + Future uploadFilesToDevice( + String remoteDir, { + void Function(int done, int total, String name)? onProgress, + }) async { + if (selectedDevice == null) return 0; + final picked = await FilePicker.pickFiles( + dialogTitle: 'Select files to upload', + ); + final paths = picked.map((f) => f.path).whereType().toList(); + if (paths.isEmpty) return null; + + _setBusy(true); + var success = 0; + try { + for (var i = 0; i < paths.length; i++) { + final name = p.basename(paths[i]); + onProgress?.call(i, paths.length, name); + try { + await _adbService.pushFile( + selectedDevice!.serial, + paths[i], + remoteDir, + ); + _onLog("Uploaded $name to $remoteDir", 'info'); + success++; + } catch (e) { + _onLog("Failed to upload $name: $e", 'error'); + } + } + } finally { + _setBusy(false); + } + return success; + } + + /// Pulls [entry] into the save location. Returns the local path on success. + Future downloadDeviceEntry(DeviceFileEntry entry) async { + if (selectedDevice == null || saveLocation.isEmpty) return null; + _setBusy(true); + try { + final localPath = _uniqueLocalPath(p.join(saveLocation, entry.name)); + await _adbService.pullEntry( + selectedDevice!.serial, + entry.path, + localPath, + ); + _onLog("Downloaded ${entry.name} to $localPath", 'info'); + return localPath; + } catch (e) { + _onLog("Failed to download ${entry.name}: $e", 'error'); + return null; + } finally { + _setBusy(false); + } + } + + String _uniqueLocalPath(String path) { + if (!File(path).existsSync() && !Directory(path).existsSync()) return path; + final dir = p.dirname(path); + final base = p.basenameWithoutExtension(path); + final ext = p.extension(path); + for (var i = 1; ; i++) { + final candidate = p.join(dir, '${base}_$i$ext'); + if (!File(candidate).existsSync() && !Directory(candidate).existsSync()) { + return candidate; + } + } + } + + Future createDeviceFolder(String parentDir, String name) async { + if (selectedDevice == null) return false; + final path = joinRemotePath(parentDir, name); + final ok = await _adbService.makeDirectory(selectedDevice!.serial, path); + _onLog( + ok ? "Created folder $path" : "Failed to create folder $path", + ok ? 'info' : 'error', + ); + return ok; + } + + Future renameDeviceEntry(DeviceFileEntry entry, String newName) async { + if (selectedDevice == null) return false; + final target = joinRemotePath(parentRemotePath(entry.path), newName); + final ok = await _adbService.renameEntry( + selectedDevice!.serial, + entry.path, + target, + ); + _onLog( + ok + ? "Renamed ${entry.name} to $newName" + : "Failed to rename ${entry.name}", + ok ? 'info' : 'error', + ); + return ok; + } + + Future deleteDeviceEntry(DeviceFileEntry entry) async { + if (selectedDevice == null) return false; + _setBusy(true); + try { + final ok = await _adbService.deleteEntry( + selectedDevice!.serial, + entry.path, + ); + _onLog( + ok ? "Deleted ${entry.path}" : "Failed to delete ${entry.path}", + ok ? 'info' : 'error', + ); + return ok; + } finally { + _setBusy(false); + } + } + void openSaveLocation() { if (_saveLocation != null) { // use url_launcher or process to open folder diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7595e76..53241d1 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../widgets/log_panel.dart'; import '../widgets/app_list_widget.dart'; +import '../widgets/file_manager_dialog.dart'; import '../widgets/guide_dialog.dart'; import '../widgets/installed_app_manager_dialog.dart'; import '../widgets/peloton_uninstaller_dialog.dart'; @@ -79,6 +80,17 @@ class HomeScreen extends StatelessWidget { builder: (_) => const InstalledAppManagerDialog(), ); } + } else if (value == 'file_manager') { + if (provider.selectedDevice == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("No device selected")), + ); + } else { + showDialog( + context: context, + builder: (_) => const FileManagerDialog(), + ); + } } else if (value == 'dev_options') { if (provider.selectedDevice == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -120,6 +132,10 @@ class HomeScreen extends StatelessWidget { value: 'installed_apps', child: Text('Installed App Manager'), ), + const PopupMenuItem( + value: 'file_manager', + child: Text('File Manager'), + ), const PopupMenuItem( value: 'uninstall', child: Text('Uninstall Peloton Apps'), diff --git a/lib/services/adb_service.dart b/lib/services/adb_service.dart index 5e25895..c597773 100644 --- a/lib/services/adb_service.dart +++ b/lib/services/adb_service.dart @@ -10,7 +10,9 @@ import 'package:flutter_adb/flutter_adb.dart'; import 'package:multicast_dns/multicast_dns.dart'; import 'package:network_info_plus/network_info_plus.dart'; import '../models/device_model.dart'; +import '../models/device_file_model.dart'; import '../models/installed_app_model.dart'; +import 'device_file_parser.dart'; class AdbService { static const bundledPlatformToolsRevision = '37.0.1'; @@ -1612,6 +1614,85 @@ class AdbService { await runAdbCommand(['-s', serial, 'pull', remotePath, localPath]); await runAdbCommand(['-s', serial, 'shell', 'rm', remotePath]); } + + // --------------------------------------------------------------------------- + // File manager + // --------------------------------------------------------------------------- + + Future> listDirectory( + String serial, + String path, + ) async { + // Trailing slash makes ls follow symlinked directories such as /sdcard. + final target = path.endsWith('/') ? path : '$path/'; + final output = await _runShellCommandText( + serial, + 'ls -la ${quoteShellArg(target)}', + ); + final lower = output.toLowerCase(); + if (lower.contains('permission denied') || + lower.contains('no such file') || + lower.contains('not a directory')) { + throw Exception(output.trim().split('\n').first); + } + return parseLsOutput(output, path); + } + + Future pushFile( + String serial, + String localPath, + String remoteDir, + ) async { + if (isMobile) { + throw Exception("Uploading files is not supported on the mobile client."); + } + await runAdbCommand(['-s', serial, 'push', localPath, remoteDir]); + } + + Future pullEntry( + String serial, + String remotePath, + String localPath, + ) async { + if (isMobile) { + if (_connectedIp == null) throw Exception("No device connected."); + final success = await Adb.downloadFile( + remotePath, + localPath, + ip: _connectedIp!, + port: _connectedPort, + ); + if (!success) throw Exception("Failed to download $remotePath"); + return; + } + await runAdbCommand(['-s', serial, 'pull', remotePath, localPath]); + } + + Future makeDirectory(String serial, String path) => + _runSilentShellCommand(serial, 'mkdir -p ${quoteShellArg(path)}'); + + Future renameEntry(String serial, String from, String to) => + _runSilentShellCommand( + serial, + 'mv ${quoteShellArg(from)} ${quoteShellArg(to)}', + ); + + Future deleteEntry(String serial, String path) => + _runSilentShellCommand(serial, 'rm -rf ${quoteShellArg(path)}'); + + /// Runs a shell command that prints nothing on success and returns whether + /// it stayed silent. + Future _runSilentShellCommand(String serial, String command) async { + try { + final output = await _runShellCommandText(serial, command); + final ok = output.trim().isEmpty; + if (!ok) onLog(output.trim(), 'error'); + return ok; + } catch (e) { + onLog("Shell command failed: $e", 'error'); + return false; + } + } } String? parsePlatformToolsRevision(String sourceProperties) { diff --git a/lib/services/device_file_parser.dart b/lib/services/device_file_parser.dart new file mode 100644 index 0000000..7729080 --- /dev/null +++ b/lib/services/device_file_parser.dart @@ -0,0 +1,72 @@ +import 'package:path/path.dart' as p; +import '../models/device_file_model.dart'; + +final _posix = p.posix; + +/// Quotes [value] for safe use inside an Android shell command string. +String quoteShellArg(String value) => "'${value.replaceAll("'", r"'\''")}'"; + +String joinRemotePath(String dir, String name) => + _posix.normalize(_posix.join(dir, name)); + +String parentRemotePath(String path) { + final normalized = _posix.normalize(path); + if (normalized == '/' || normalized.isEmpty) return '/'; + return _posix.dirname(normalized); +} + +final _lsLine = RegExp( + r'^([\-dlcbps])[rwxsStT\-]{9}[+.@]?\s+(.*?)\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)\s(.*)$', +); + +/// Parses `ls -la` output from Android's toybox or legacy toolbox shells. +/// +/// Toybox: `drwxrwx--x 2 root sdcard_rw 4096 2024-01-01 12:00 Name` +/// Toolbox: `drwxrwx--x root sdcard_rw 2024-01-01 12:00 Name` +List parseLsOutput(String output, String parentPath) { + final entries = []; + + for (final rawLine in output.split('\n')) { + final line = rawLine.trimRight(); + if (line.isEmpty || line.startsWith('total ')) continue; + + final match = _lsLine.firstMatch(line); + if (match == null) continue; + + final typeChar = match.group(1)!; + final middle = match.group(2)!.trim(); + final date = match.group(3)!; + final time = match.group(4)!; + var name = match.group(5)!; + + final isSymlink = typeChar == 'l'; + if (isSymlink) { + final arrow = name.indexOf(' -> '); + if (arrow >= 0) name = name.substring(0, arrow); + } + if (name.isEmpty || name == '.' || name == '..') continue; + + int? size; + final middleTokens = middle.split(RegExp(r'\s+')); + if (middleTokens.length >= 3) { + size = int.tryParse(middleTokens.last); + } + + entries.add( + DeviceFileEntry( + name: name, + path: joinRemotePath(parentPath, name), + isDirectory: typeChar == 'd', + isSymlink: isSymlink, + sizeBytes: typeChar == '-' ? size : null, + modified: '$date $time', + ), + ); + } + + entries.sort((a, b) { + if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + return entries; +} diff --git a/lib/widgets/file_manager_dialog.dart b/lib/widgets/file_manager_dialog.dart new file mode 100644 index 0000000..4442b2a --- /dev/null +++ b/lib/widgets/file_manager_dialog.dart @@ -0,0 +1,505 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/device_file_model.dart'; +import '../providers/app_provider.dart'; +import '../services/device_file_parser.dart'; +import '../theme/app_theme.dart'; +import 'draggable_dialog.dart'; + +class FileManagerDialog extends StatefulWidget { + const FileManagerDialog({super.key}); + + @override + State createState() => _FileManagerDialogState(); +} + +class _FileManagerDialogState extends State { + static const _startPath = '/sdcard'; + static const _shortcuts = { + 'sdcard': '/sdcard', + 'Download': '/sdcard/Download', + 'DCIM': '/sdcard/DCIM', + 'Movies': '/sdcard/Movies', + }; + + final TextEditingController _pathController = TextEditingController( + text: _startPath, + ); + String _currentPath = _startPath; + List _entries = []; + bool _loading = true; + bool _transferring = false; + String? _transferLabel; + double? _transferProgress; + String? _statusMessage; + bool _statusIsError = false; + String? _lastDownloadPath; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _navigateTo(_startPath, fallbackToRoot: true), + ); + } + + @override + void dispose() { + _pathController.dispose(); + super.dispose(); + } + + AppProvider get _provider => Provider.of(context, listen: false); + + bool get _busy => _loading || _transferring; + + Future _navigateTo(String path, {bool fallbackToRoot = false}) async { + final target = path.trim().isEmpty ? '/' : joinRemotePath('/', path.trim()); + setState(() => _loading = true); + try { + final entries = await _provider.listDeviceFiles(target); + if (!mounted) return; + setState(() { + _entries = entries; + _currentPath = target; + _pathController.text = target; + _statusMessage = null; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + if (fallbackToRoot && target != '/') { + await _navigateTo('/'); + return; + } + setState(() { + _pathController.text = _currentPath; + _loading = false; + _statusMessage = "Could not open $target: ${_shortError(e)}"; + _statusIsError = true; + }); + } + } + + String _shortError(Object e) { + final text = e.toString(); + return text.startsWith('Exception: ') ? text.substring(11) : text; + } + + Future _refresh() => _navigateTo(_currentPath); + + void _goUp() => _navigateTo(parentRemotePath(_currentPath)); + + void _setStatus(String message, {bool isError = false}) { + if (!mounted) return; + setState(() { + _statusMessage = message; + _statusIsError = isError; + }); + } + + Future _upload() async { + setState(() { + _transferring = true; + _transferLabel = "Waiting for file selection..."; + _transferProgress = null; + _lastDownloadPath = null; + }); + final count = await _provider.uploadFilesToDevice( + _currentPath, + onProgress: (done, total, name) { + if (!mounted) return; + setState(() { + _transferLabel = total == 1 + ? "Uploading $name..." + : "Uploading ${done + 1} of $total: $name"; + _transferProgress = total == 1 ? null : done / total; + }); + }, + ); + if (!mounted) return; + setState(() { + _transferring = false; + _transferLabel = null; + }); + if (count == null) return; + _setStatus( + count == 0 + ? "Upload failed. See the log for details." + : "Uploaded $count file${count == 1 ? '' : 's'} to $_currentPath", + isError: count == 0, + ); + await _refresh(); + } + + Future _download(DeviceFileEntry entry) async { + if (_provider.saveLocation.isEmpty) { + _setStatus( + "Choose a save location on the main screen first.", + isError: true, + ); + return; + } + setState(() { + _transferring = true; + _transferLabel = "Downloading ${entry.name}..."; + _transferProgress = null; + _lastDownloadPath = null; + }); + final path = await _provider.downloadDeviceEntry(entry); + if (!mounted) return; + setState(() { + _transferring = false; + _transferLabel = null; + _lastDownloadPath = path; + }); + _setStatus( + path == null ? "Could not download ${entry.name}" : "Saved to $path", + isError: path == null, + ); + } + + Future _promptForName( + String title, + String actionLabel, { + String initial = '', + }) async { + final controller = TextEditingController(text: initial); + String? error; + final result = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + void submit() { + final value = controller.text.trim(); + if (value.isEmpty || value == '.' || value == '..') { + setDialogState(() => error = "Enter a name."); + return; + } + if (value.contains('/')) { + setDialogState(() => error = "Names cannot contain '/'."); + return; + } + Navigator.pop(ctx, value); + } + + return AlertDialog( + title: Text(title), + content: SizedBox( + width: 360, + child: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: "Name", + isDense: true, + errorText: error, + ), + onSubmitted: (_) => submit(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("Cancel"), + ), + TextButton(onPressed: submit, child: Text(actionLabel)), + ], + ); + }, + ), + ); + controller.dispose(); + return result; + } + + Future _createFolder() async { + final name = await _promptForName("New Folder", "Create"); + if (name == null || !mounted) return; + final ok = await _provider.createDeviceFolder(_currentPath, name); + _setStatus( + ok ? "Created folder $name" : "Could not create folder $name", + isError: !ok, + ); + if (ok) await _refresh(); + } + + Future _rename(DeviceFileEntry entry) async { + final name = await _promptForName("Rename", "Rename", initial: entry.name); + if (name == null || name == entry.name || !mounted) return; + final ok = await _provider.renameDeviceEntry(entry, name); + _setStatus( + ok ? "Renamed ${entry.name} to $name" : "Could not rename ${entry.name}", + isError: !ok, + ); + if (ok) await _refresh(); + } + + Future _confirmDelete(DeviceFileEntry entry) async { + final colorScheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(entry.isDirectory ? "Delete Folder" : "Delete File"), + content: Text( + entry.isDirectory + ? "Delete ${entry.name} and everything inside it?\n\n" + "This cannot be undone." + : "Delete ${entry.name}?\n\nThis cannot be undone.", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: const Text("Delete"), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final ok = await _provider.deleteDeviceEntry(entry); + _setStatus( + ok ? "Deleted ${entry.name}" : "Could not delete ${entry.name}", + isError: !ok, + ); + if (ok) await _refresh(); + } + + @override + Widget build(BuildContext context) { + return DraggableDialog( + width: 900, + height: 680, + leading: const Icon(Icons.folder_open), + title: const Text( + "File Manager", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + actions: [ + IconButton( + tooltip: "Upload files here", + onPressed: _busy ? null : _upload, + icon: const Icon(Icons.upload_file), + ), + IconButton( + tooltip: "New folder", + onPressed: _busy ? null : _createFolder, + icon: const Icon(Icons.create_new_folder_outlined), + ), + IconButton( + tooltip: "Refresh", + onPressed: _busy ? null : _refresh, + icon: const Icon(Icons.refresh), + ), + IconButton( + tooltip: "Close", + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + footer: _buildStatusBar(), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + IconButton( + tooltip: "Up one level", + onPressed: _busy || _currentPath == '/' ? null : _goUp, + icon: const Icon(Icons.arrow_upward), + ), + const SizedBox(width: 4), + Expanded( + child: TextField( + controller: _pathController, + enabled: !_busy, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.folder), + labelText: "Device path", + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (value) => _navigateTo(value), + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 6, + children: _shortcuts.entries + .map( + (shortcut) => ActionChip( + label: Text(shortcut.key), + onPressed: _busy + ? null + : () => _navigateTo(shortcut.value), + ), + ) + .toList(), + ), + ], + ), + ), + Expanded(child: _buildFileList()), + ], + ), + ); + } + + Widget _buildFileList() { + if (_loading && _entries.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_entries.isEmpty) { + return const Center(child: Text("This folder is empty.")); + } + + return ListView.separated( + itemCount: _entries.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final entry = _entries[index]; + return _DeviceFileTile( + entry: entry, + enabled: !_busy, + onOpen: entry.isDirectory || entry.isSymlink + ? () => _navigateTo(entry.path) + : null, + onDownload: () => _download(entry), + onRename: () => _rename(entry), + onDelete: () => _confirmDelete(entry), + ); + }, + ); + } + + Widget _buildStatusBar() { + final colorScheme = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.bodySmall; + final defaultText = + "${_entries.length} item${_entries.length == 1 ? '' : 's'} in " + "$_currentPath"; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_transferring) + LinearProgressIndicator(value: _transferProgress, minHeight: 2), + Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: colorScheme.surfaceContainerHighest, + child: Row( + children: [ + Expanded( + child: Text( + _transferLabel ?? _statusMessage ?? defaultText, + overflow: TextOverflow.ellipsis, + style: textStyle?.copyWith( + color: _statusIsError && _transferLabel == null + ? colorScheme.error + : null, + fontWeight: _statusMessage == null && _transferLabel == null + ? FontWeight.normal + : FontWeight.w600, + ), + ), + ), + if (_lastDownloadPath != null && _statusMessage != null) + IconButton( + tooltip: "Open save folder", + onPressed: _provider.openSaveLocation, + icon: const Icon(Icons.folder, size: 18), + visualDensity: VisualDensity.compact, + ), + if (_statusMessage != null && !_transferring) + IconButton( + tooltip: "Clear status", + onPressed: () => setState(() { + _statusMessage = null; + _lastDownloadPath = null; + }), + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ], + ); + } +} + +class _DeviceFileTile extends StatelessWidget { + final DeviceFileEntry entry; + final bool enabled; + final VoidCallback? onOpen; + final VoidCallback onDownload; + final VoidCallback onRename; + final VoidCallback onDelete; + + const _DeviceFileTile({ + required this.entry, + required this.enabled, + required this.onOpen, + required this.onDownload, + required this.onRename, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final details = [ + if (entry.sizeBytes != null) formatFileSize(entry.sizeBytes!), + if (entry.modified != null) entry.modified!, + ]; + + return ListTile( + enabled: enabled, + onTap: enabled ? onOpen : null, + leading: Icon( + entry.isDirectory + ? Icons.folder + : entry.isSymlink + ? Icons.link + : Icons.insert_drive_file_outlined, + color: entry.isDirectory ? colorScheme.secondary : null, + ), + title: Text(entry.name, overflow: TextOverflow.ellipsis), + subtitle: details.isEmpty + ? null + : Text( + details.join(' · '), + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: "Download to save location", + onPressed: enabled ? onDownload : null, + icon: const Icon(Icons.download), + ), + IconButton( + tooltip: "Rename", + onPressed: enabled ? onRename : null, + icon: const Icon(Icons.drive_file_rename_outline), + ), + IconButton( + tooltip: "Delete", + onPressed: enabled ? onDelete : null, + color: AppColors.danger, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + } +} diff --git a/test/device_file_parser_test.dart b/test/device_file_parser_test.dart new file mode 100644 index 0000000..bba1a34 --- /dev/null +++ b/test/device_file_parser_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openpelo/models/device_file_model.dart'; +import 'package:openpelo/services/device_file_parser.dart'; + +void main() { + group('parseLsOutput', () { + test('parses toybox output with sizes and sorts directories first', () { + const output = ''' +total 24 +drwxrwx--x 4 root sdcard_rw 4096 2024-01-01 12:00 . +drwxrwx--x 10 root sdcard_rw 4096 2024-01-01 12:00 .. +-rw-rw---- 1 root sdcard_rw 2048 2024-03-05 09:30 zebra.txt +drwxrwx--x 2 root sdcard_rw 4096 2024-02-02 08:00 Download +-rw-rw---- 1 root sdcard_rw 512 2024-03-06 10:15 My File.mp4 +'''; + + final entries = parseLsOutput(output, '/sdcard'); + + expect(entries.map((e) => e.name), [ + 'Download', + 'My File.mp4', + 'zebra.txt', + ]); + expect(entries[0].isDirectory, isTrue); + expect(entries[0].path, '/sdcard/Download'); + expect(entries[0].sizeBytes, isNull); + expect(entries[1].sizeBytes, 512); + expect(entries[1].modified, '2024-03-06 10:15'); + expect(entries[2].sizeBytes, 2048); + }); + + test('parses legacy toolbox output without link counts or dir sizes', () { + const output = ''' +drwxrwx--x root sdcard_rw 2016-05-01 12:00 DCIM +-rw-rw---- root sdcard_rw 1024 2016-05-02 13:45 notes.txt +'''; + + final entries = parseLsOutput(output, '/sdcard'); + + expect(entries.length, 2); + expect(entries[0].name, 'DCIM'); + expect(entries[0].isDirectory, isTrue); + expect(entries[1].name, 'notes.txt'); + expect(entries[1].sizeBytes, 1024); + }); + + test('strips symlink targets and flags them', () { + const output = + 'lrwxrwxrwx 1 root root 21 2024-01-01 00:00 sdcard -> /storage/self/primary'; + + final entries = parseLsOutput(output, '/'); + + expect(entries.single.name, 'sdcard'); + expect(entries.single.isSymlink, isTrue); + expect(entries.single.isDirectory, isFalse); + expect(entries.single.path, '/sdcard'); + }); + + test('ignores unparseable lines', () { + const output = 'ls: /data: Permission denied'; + expect(parseLsOutput(output, '/data'), isEmpty); + }); + }); + + group('path helpers', () { + test('quoteShellArg escapes embedded single quotes', () { + expect(quoteShellArg("Rock 'n' Roll"), r"'Rock '\''n'\'' Roll'"); + expect(quoteShellArg('/sdcard/My Folder'), "'/sdcard/My Folder'"); + }); + + test('parentRemotePath walks up to root', () { + expect(parentRemotePath('/sdcard/Download'), '/sdcard'); + expect(parentRemotePath('/sdcard'), '/'); + expect(parentRemotePath('/'), '/'); + }); + + test('joinRemotePath normalizes separators', () { + expect(joinRemotePath('/sdcard/', 'Movies'), '/sdcard/Movies'); + expect(joinRemotePath('/', 'sdcard'), '/sdcard'); + }); + }); + + group('formatFileSize', () { + test('formats byte counts', () { + expect(formatFileSize(512), '512 B'); + expect(formatFileSize(2048), '2.0 KB'); + expect(formatFileSize(5 * 1024 * 1024), '5.0 MB'); + expect(formatFileSize(150 * 1024 * 1024), '150 MB'); + }); + }); +} From 920b721f5c03f327deed763dca31ca3c485ffb36 Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:28:01 -0400 Subject: [PATCH 2/7] Create .gitattributes --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..488bbfe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize line endings to LF +* text=auto eol=lf From b9a7d82ef3076e6c90fe0b821c7c6d4bb85052f7 Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:05:55 -0400 Subject: [PATCH 3/7] harden file handling --- lib/providers/app_provider.dart | 64 ++++++------ lib/screens/home_screen.dart | 7 +- lib/services/adb_service.dart | 148 ++++++++++++++++++++++++--- lib/services/device_file_parser.dart | 19 ++++ lib/widgets/file_manager_dialog.dart | 41 ++++++-- test/device_file_parser_test.dart | 16 +++ 6 files changed, 236 insertions(+), 59 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 030295e..f0f8717 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1297,19 +1297,21 @@ class AppProvider with ChangeNotifier { // File manager // --------------------------------------------------------------------------- - Future> listDeviceFiles(String path) async { - if (selectedDevice == null) return []; - return await _adbService.listDirectory(selectedDevice!.serial, path); + Future> listDeviceFiles( + String deviceSerial, + String path, + ) async { + return await _adbService.listDirectory(deviceSerial, path); } /// Lets the user pick local files and pushes them into [remoteDir]. /// Returns the number of files uploaded successfully, or null if the picker /// was cancelled. Future uploadFilesToDevice( + String deviceSerial, String remoteDir, { void Function(int done, int total, String name)? onProgress, }) async { - if (selectedDevice == null) return 0; final picked = await FilePicker.pickFiles( dialogTitle: 'Select files to upload', ); @@ -1323,11 +1325,7 @@ class AppProvider with ChangeNotifier { final name = p.basename(paths[i]); onProgress?.call(i, paths.length, name); try { - await _adbService.pushFile( - selectedDevice!.serial, - paths[i], - remoteDir, - ); + await _adbService.pushFile(deviceSerial, paths[i], remoteDir); _onLog("Uploaded $name to $remoteDir", 'info'); success++; } catch (e) { @@ -1341,16 +1339,17 @@ class AppProvider with ChangeNotifier { } /// Pulls [entry] into the save location. Returns the local path on success. - Future downloadDeviceEntry(DeviceFileEntry entry) async { - if (selectedDevice == null || saveLocation.isEmpty) return null; + Future downloadDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, + ) async { + if (saveLocation.isEmpty) return null; _setBusy(true); try { - final localPath = _uniqueLocalPath(p.join(saveLocation, entry.name)); - await _adbService.pullEntry( - selectedDevice!.serial, - entry.path, - localPath, + final localPath = _uniqueLocalPath( + safeLocalEntryPath(saveLocation, entry.name), ); + await _adbService.pullEntry(deviceSerial, entry.path, localPath); _onLog("Downloaded ${entry.name} to $localPath", 'info'); return localPath; } catch (e) { @@ -1374,10 +1373,13 @@ class AppProvider with ChangeNotifier { } } - Future createDeviceFolder(String parentDir, String name) async { - if (selectedDevice == null) return false; + Future createDeviceFolder( + String deviceSerial, + String parentDir, + String name, + ) async { final path = joinRemotePath(parentDir, name); - final ok = await _adbService.makeDirectory(selectedDevice!.serial, path); + final ok = await _adbService.makeDirectory(deviceSerial, path); _onLog( ok ? "Created folder $path" : "Failed to create folder $path", ok ? 'info' : 'error', @@ -1385,14 +1387,13 @@ class AppProvider with ChangeNotifier { return ok; } - Future renameDeviceEntry(DeviceFileEntry entry, String newName) async { - if (selectedDevice == null) return false; + Future renameDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, + String newName, + ) async { final target = joinRemotePath(parentRemotePath(entry.path), newName); - final ok = await _adbService.renameEntry( - selectedDevice!.serial, - entry.path, - target, - ); + final ok = await _adbService.renameEntry(deviceSerial, entry.path, target); _onLog( ok ? "Renamed ${entry.name} to $newName" @@ -1402,14 +1403,13 @@ class AppProvider with ChangeNotifier { return ok; } - Future deleteDeviceEntry(DeviceFileEntry entry) async { - if (selectedDevice == null) return false; + Future deleteDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, + ) async { _setBusy(true); try { - final ok = await _adbService.deleteEntry( - selectedDevice!.serial, - entry.path, - ); + final ok = await _adbService.deleteEntry(deviceSerial, entry.path); _onLog( ok ? "Deleted ${entry.path}" : "Failed to delete ${entry.path}", ok ? 'info' : 'error', diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 53241d1..47404a4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -81,14 +81,17 @@ class HomeScreen extends StatelessWidget { ); } } else if (value == 'file_manager') { - if (provider.selectedDevice == null) { + final deviceSerial = provider.selectedDevice?.serial; + if (deviceSerial == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("No device selected")), ); } else { showDialog( context: context, - builder: (_) => const FileManagerDialog(), + builder: (_) => FileManagerDialog( + deviceSerial: deviceSerial, + ), ); } } else if (value == 'dev_options') { diff --git a/lib/services/adb_service.dart b/lib/services/adb_service.dart index c597773..58054e3 100644 --- a/lib/services/adb_service.dart +++ b/lib/services/adb_service.dart @@ -1646,7 +1646,32 @@ class AdbService { if (isMobile) { throw Exception("Uploading files is not supported on the mobile client."); } - await runAdbCommand(['-s', serial, 'push', localPath, remoteDir]); + final remotePath = joinRemotePath(remoteDir, p.basename(localPath)); + if (await _remoteEntryExists(serial, remotePath)) { + throw Exception('A file named ${p.basename(localPath)} already exists.'); + } + + final temporaryPath = await _unusedRemoteTransferPath(serial, remoteDir); + try { + await runAdbCommand(['-s', serial, 'push', localPath, temporaryPath]); + final renameResult = await _renameEntryNoClobber( + serial, + temporaryPath, + remotePath, + ); + if (renameResult == _RenameEntryResult.destinationExists) { + throw Exception('A file named ${p.basename(localPath)} already exists.'); + } + if (renameResult == _RenameEntryResult.failed) { + throw Exception('Could not finish uploading ${p.basename(localPath)}.'); + } + } catch (_) { + await _runSilentShellCommand( + serial, + 'rm -rf ${quoteShellArg(temporaryPath)}', + ); + rethrow; + } } Future pullEntry( @@ -1654,28 +1679,64 @@ class AdbService { String remotePath, String localPath, ) async { - if (isMobile) { - if (_connectedIp == null) throw Exception("No device connected."); - final success = await Adb.downloadFile( - remotePath, - localPath, - ip: _connectedIp!, - port: _connectedPort, - ); - if (!success) throw Exception("Failed to download $remotePath"); - return; + final temporaryPath = await _unusedLocalTransferPath(localPath); + try { + if (isMobile) { + if (_connectedIp == null) throw Exception("No device connected."); + final success = await Adb.downloadFile( + remotePath, + temporaryPath, + ip: _connectedIp!, + port: _connectedPort, + ); + if (!success) throw Exception("Failed to download $remotePath"); + } else { + await runAdbCommand(['-s', serial, 'pull', remotePath, temporaryPath]); + } + await _moveLocalEntry(temporaryPath, localPath); + } catch (_) { + await _deleteLocalEntry(temporaryPath); + rethrow; } - await runAdbCommand(['-s', serial, 'pull', remotePath, localPath]); } Future makeDirectory(String serial, String path) => _runSilentShellCommand(serial, 'mkdir -p ${quoteShellArg(path)}'); - Future renameEntry(String serial, String from, String to) => - _runSilentShellCommand( + Future renameEntry(String serial, String from, String to) async { + final result = await _renameEntryNoClobber(serial, from, to); + if (result == _RenameEntryResult.destinationExists) { + onLog('Destination already exists: $to', 'error'); + } + return result == _RenameEntryResult.renamed; + } + + Future<_RenameEntryResult> _renameEntryNoClobber( + String serial, + String from, + String to, + ) async { + const destinationExistsMarker = '__OPENPELO_DESTINATION_EXISTS__'; + final quotedTo = quoteShellArg(to); + try { + final output = await _runShellCommandText( serial, - 'mv ${quoteShellArg(from)} ${quoteShellArg(to)}', + 'if [ -e $quotedTo ] || [ -L $quotedTo ]; then ' + 'printf $destinationExistsMarker; ' + 'else mv ${quoteShellArg(from)} $quotedTo; fi', ); + final text = output.trim(); + if (text == destinationExistsMarker) { + return _RenameEntryResult.destinationExists; + } + if (text.isEmpty) return _RenameEntryResult.renamed; + onLog(text, 'error'); + return _RenameEntryResult.failed; + } catch (e) { + onLog('Shell command failed: $e', 'error'); + return _RenameEntryResult.failed; + } + } Future deleteEntry(String serial, String path) => _runSilentShellCommand(serial, 'rm -rf ${quoteShellArg(path)}'); @@ -1693,8 +1754,65 @@ class AdbService { return false; } } + + Future _remoteEntryExists(String serial, String path) async { + final quotedPath = quoteShellArg(path); + final output = await _runShellCommandText( + serial, + 'if [ -e $quotedPath ] || [ -L $quotedPath ]; then printf 1; ' + 'else printf 0; fi', + ); + return output.trim() == '1'; + } + + Future _unusedRemoteTransferPath( + String serial, + String remoteDir, + ) async { + final stamp = DateTime.now().microsecondsSinceEpoch; + for (var suffix = 0; ; suffix++) { + final name = '.openpelo-upload-$stamp${suffix == 0 ? '' : '-$suffix'}'; + final candidate = joinRemotePath(remoteDir, name); + if (!await _remoteEntryExists(serial, candidate)) return candidate; + } + } + + Future _unusedLocalTransferPath(String localPath) async { + final directory = p.dirname(localPath); + final stamp = DateTime.now().microsecondsSinceEpoch; + for (var suffix = 0; ; suffix++) { + final name = '.openpelo-download-$stamp${suffix == 0 ? '' : '-$suffix'}'; + final candidate = p.join(directory, name); + if (!await File(candidate).exists() && + !await Directory(candidate).exists()) { + return candidate; + } + } + } + + Future _moveLocalEntry(String from, String to) async { + if (await File(from).exists()) { + await File(from).rename(to); + return; + } + if (await Directory(from).exists()) { + await Directory(from).rename(to); + return; + } + throw Exception('Downloaded entry was not created.'); + } + + Future _deleteLocalEntry(String path) async { + if (await File(path).exists()) { + await File(path).delete(); + } else if (await Directory(path).exists()) { + await Directory(path).delete(recursive: true); + } + } } +enum _RenameEntryResult { renamed, destinationExists, failed } + String? parsePlatformToolsRevision(String sourceProperties) { for (final line in const LineSplitter().convert(sourceProperties)) { final match = RegExp(r'^Pkg\.Revision\s*=\s*(\S+)\s*$').firstMatch(line); diff --git a/lib/services/device_file_parser.dart b/lib/services/device_file_parser.dart index 7729080..1b02e8e 100644 --- a/lib/services/device_file_parser.dart +++ b/lib/services/device_file_parser.dart @@ -15,6 +15,25 @@ String parentRemotePath(String path) { return _posix.dirname(normalized); } +/// Builds a local destination for a remote entry without allowing the remote +/// name to introduce host path separators or escape [root]. +String safeLocalEntryPath(String root, String name) { + if (name.isEmpty || + name == '.' || + name == '..' || + name.contains('/') || + name.contains(r'\')) { + throw FormatException('Unsupported device filename: $name'); + } + + final normalizedRoot = p.normalize(p.absolute(root)); + final candidate = p.normalize(p.join(normalizedRoot, name)); + if (!p.isWithin(normalizedRoot, candidate)) { + throw FormatException('Device filename escapes the save location: $name'); + } + return candidate; +} + final _lsLine = RegExp( r'^([\-dlcbps])[rwxsStT\-]{9}[+.@]?\s+(.*?)\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)\s(.*)$', ); diff --git a/lib/widgets/file_manager_dialog.dart b/lib/widgets/file_manager_dialog.dart index 4442b2a..bf8ace6 100644 --- a/lib/widgets/file_manager_dialog.dart +++ b/lib/widgets/file_manager_dialog.dart @@ -7,7 +7,9 @@ import '../theme/app_theme.dart'; import 'draggable_dialog.dart'; class FileManagerDialog extends StatefulWidget { - const FileManagerDialog({super.key}); + final String deviceSerial; + + const FileManagerDialog({super.key, required this.deviceSerial}); @override State createState() => _FileManagerDialogState(); @@ -51,13 +53,19 @@ class _FileManagerDialogState extends State { AppProvider get _provider => Provider.of(context, listen: false); - bool get _busy => _loading || _transferring; + bool get _deviceSessionActive => + _provider.selectedDevice?.serial == widget.deviceSerial; + + bool get _busy => _loading || _transferring || !_deviceSessionActive; Future _navigateTo(String path, {bool fallbackToRoot = false}) async { final target = path.trim().isEmpty ? '/' : joinRemotePath('/', path.trim()); setState(() => _loading = true); try { - final entries = await _provider.listDeviceFiles(target); + final entries = await _provider.listDeviceFiles( + widget.deviceSerial, + target, + ); if (!mounted) return; setState(() { _entries = entries; @@ -106,6 +114,7 @@ class _FileManagerDialogState extends State { _lastDownloadPath = null; }); final count = await _provider.uploadFilesToDevice( + widget.deviceSerial, _currentPath, onProgress: (done, total, name) { if (!mounted) return; @@ -146,7 +155,7 @@ class _FileManagerDialogState extends State { _transferProgress = null; _lastDownloadPath = null; }); - final path = await _provider.downloadDeviceEntry(entry); + final path = await _provider.downloadDeviceEntry(widget.deviceSerial, entry); if (!mounted) return; setState(() { _transferring = false; @@ -216,7 +225,11 @@ class _FileManagerDialogState extends State { Future _createFolder() async { final name = await _promptForName("New Folder", "Create"); if (name == null || !mounted) return; - final ok = await _provider.createDeviceFolder(_currentPath, name); + final ok = await _provider.createDeviceFolder( + widget.deviceSerial, + _currentPath, + name, + ); _setStatus( ok ? "Created folder $name" : "Could not create folder $name", isError: !ok, @@ -227,7 +240,11 @@ class _FileManagerDialogState extends State { Future _rename(DeviceFileEntry entry) async { final name = await _promptForName("Rename", "Rename", initial: entry.name); if (name == null || name == entry.name || !mounted) return; - final ok = await _provider.renameDeviceEntry(entry, name); + final ok = await _provider.renameDeviceEntry( + widget.deviceSerial, + entry, + name, + ); _setStatus( ok ? "Renamed ${entry.name} to $name" : "Could not rename ${entry.name}", isError: !ok, @@ -262,7 +279,7 @@ class _FileManagerDialogState extends State { ); if (confirmed != true || !mounted) return; - final ok = await _provider.deleteDeviceEntry(entry); + final ok = await _provider.deleteDeviceEntry(widget.deviceSerial, entry); _setStatus( ok ? "Deleted ${entry.name}" : "Could not delete ${entry.name}", isError: !ok, @@ -272,6 +289,9 @@ class _FileManagerDialogState extends State { @override Widget build(BuildContext context) { + context.select( + (provider) => provider.selectedDevice?.serial, + ); return DraggableDialog( width: 900, height: 680, @@ -381,9 +401,10 @@ class _FileManagerDialogState extends State { Widget _buildStatusBar() { final colorScheme = Theme.of(context).colorScheme; final textStyle = Theme.of(context).textTheme.bodySmall; - final defaultText = - "${_entries.length} item${_entries.length == 1 ? '' : 's'} in " - "$_currentPath"; + final defaultText = !_deviceSessionActive + ? "The selected device changed. Close and reopen File Manager." + : "${_entries.length} item${_entries.length == 1 ? '' : 's'} in " + "$_currentPath"; return Column( mainAxisSize: MainAxisSize.min, diff --git a/test/device_file_parser_test.dart b/test/device_file_parser_test.dart index bba1a34..e7ed830 100644 --- a/test/device_file_parser_test.dart +++ b/test/device_file_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openpelo/models/device_file_model.dart'; import 'package:openpelo/services/device_file_parser.dart'; +import 'package:path/path.dart' as p; void main() { group('parseLsOutput', () { @@ -78,6 +79,21 @@ drwxrwx--x root sdcard_rw 2016-05-01 12:00 DCIM expect(joinRemotePath('/sdcard/', 'Movies'), '/sdcard/Movies'); expect(joinRemotePath('/', 'sdcard'), '/sdcard'); }); + + test('safeLocalEntryPath rejects host path traversal', () { + expect( + () => safeLocalEntryPath('downloads', r'..\outside.txt'), + throwsFormatException, + ); + expect( + () => safeLocalEntryPath('downloads', '../outside.txt'), + throwsFormatException, + ); + expect( + p.basename(safeLocalEntryPath('downloads', 'inside.txt')), + 'inside.txt', + ); + }); }); group('formatFileSize', () { From 2b83af4cd7df4c1d03dd410930e5e648a429a516 Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:33:41 -0400 Subject: [PATCH 4/7] file sorting --- lib/services/device_file_parser.dart | 41 ++++- lib/widgets/file_manager_dialog.dart | 227 ++++++++++++++++++++++----- test/device_file_parser_test.dart | 138 ++++++++++++++++ 3 files changed, 362 insertions(+), 44 deletions(-) diff --git a/lib/services/device_file_parser.dart b/lib/services/device_file_parser.dart index 1b02e8e..4895202 100644 --- a/lib/services/device_file_parser.dart +++ b/lib/services/device_file_parser.dart @@ -83,9 +83,46 @@ List parseLsOutput(String output, String parentPath) { ); } + sortDeviceFiles(entries); + return entries; +} + +/// Column a device listing can be ordered by. +enum DeviceFileSort { name, modified, size } + +/// Sorts [entries] in place. Directories always stay grouped above files; the +/// [sort] column and [ascending] direction apply within each group. Entries +/// missing a size or timestamp fall back to case-insensitive name order. +void sortDeviceFiles( + List entries, { + DeviceFileSort sort = DeviceFileSort.name, + bool ascending = true, +}) { + int byName(DeviceFileEntry a, DeviceFileEntry b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()); + entries.sort((a, b) { if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1; - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + + var result = 0; + switch (sort) { + case DeviceFileSort.name: + result = byName(a, b); + case DeviceFileSort.modified: + // Timestamps are 'YYYY-MM-DD HH:MM[:SS]', so string order is time + // order. Unknown timestamps sort last when ascending. + final am = a.modified; + final bm = b.modified; + if (am == null || bm == null) { + result = am == bm ? 0 : (am == null ? 1 : -1); + } else { + result = am.compareTo(bm); + } + case DeviceFileSort.size: + result = (a.sizeBytes ?? -1).compareTo(b.sizeBytes ?? -1); + } + + if (result == 0) return byName(a, b); + return ascending ? result : -result; }); - return entries; } diff --git a/lib/widgets/file_manager_dialog.dart b/lib/widgets/file_manager_dialog.dart index bf8ace6..ba2929b 100644 --- a/lib/widgets/file_manager_dialog.dart +++ b/lib/widgets/file_manager_dialog.dart @@ -6,6 +6,17 @@ import '../services/device_file_parser.dart'; import '../theme/app_theme.dart'; import 'draggable_dialog.dart'; +/// Column geometry shared by the sort header and the rows below it. Both use +/// the same padding and widths, so the labels sit directly over their values. +const EdgeInsets _rowPadding = EdgeInsets.symmetric(horizontal: 16); +const double _leadingColumnWidth = 40; // icon + gap +const double _sizeColumnWidth = 90; +const double _columnGap = 20; +// Fits 'YYYY-MM-DD HH:MM:SS', the longest form the ls parser produces. +const double _modifiedColumnWidth = 150; +const double _actionsColumnWidth = 144; // three IconButtons +const double _arrowSlotWidth = 18; + class FileManagerDialog extends StatefulWidget { final String deviceSerial; @@ -29,6 +40,8 @@ class _FileManagerDialogState extends State { ); String _currentPath = _startPath; List _entries = []; + DeviceFileSort _sort = DeviceFileSort.name; + bool _ascending = true; bool _loading = true; bool _transferring = false; String? _transferLabel; @@ -67,6 +80,7 @@ class _FileManagerDialogState extends State { target, ); if (!mounted) return; + sortDeviceFiles(entries, sort: _sort, ascending: _ascending); setState(() { _entries = entries; _currentPath = target; @@ -96,6 +110,20 @@ class _FileManagerDialogState extends State { Future _refresh() => _navigateTo(_currentPath); + /// Re-orders the loaded entries locally; tapping the active column flips the + /// direction instead of changing it. + void _applySort(DeviceFileSort sort) { + setState(() { + if (_sort == sort) { + _ascending = !_ascending; + } else { + _sort = sort; + _ascending = true; + } + sortDeviceFiles(_entries, sort: _sort, ascending: _ascending); + }); + } + void _goUp() => _navigateTo(parentRemotePath(_currentPath)); void _setStatus(String message, {bool isError = false}) { @@ -155,7 +183,10 @@ class _FileManagerDialogState extends State { _transferProgress = null; _lastDownloadPath = null; }); - final path = await _provider.downloadDeviceEntry(widget.deviceSerial, entry); + final path = await _provider.downloadDeviceEntry( + widget.deviceSerial, + entry, + ); if (!mounted) return; setState(() { _transferring = false; @@ -365,12 +396,79 @@ class _FileManagerDialogState extends State { ], ), ), + _buildSortHeader(), Expanded(child: _buildFileList()), ], ), ); } + Widget _buildSortHeader() { + final colorScheme = Theme.of(context).colorScheme; + + Widget cell(String label, DeviceFileSort sort, {bool alignEnd = false}) { + final active = _sort == sort; + final text = Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: active + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + ), + ), + ); + final arrow = SizedBox( + width: _arrowSlotWidth, + child: active + ? Icon( + _ascending ? Icons.arrow_upward : Icons.arrow_downward, + size: 14, + color: colorScheme.onSurface, + ) + : null, + ); + + return InkWell( + onTap: _busy ? null : () => _applySort(sort), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: alignEnd + ? MainAxisAlignment.end + : MainAxisAlignment.start, + // The arrow sits on the outside of the column so the label stays + // flush with the values it labels. + children: alignEnd ? [arrow, text] : [text, arrow], + ), + ), + ); + } + + return Container( + color: colorScheme.surfaceContainerHighest, + padding: _rowPadding, + child: Row( + children: [ + const SizedBox(width: _leadingColumnWidth), + Expanded(child: cell("Name", DeviceFileSort.name)), + SizedBox( + width: _sizeColumnWidth, + child: cell("Size", DeviceFileSort.size, alignEnd: true), + ), + const SizedBox(width: _columnGap), + SizedBox( + width: _modifiedColumnWidth, + child: cell("Modified", DeviceFileSort.modified), + ), + const SizedBox(width: _actionsColumnWidth), + ], + ), + ); + } + Widget _buildFileList() { if (_loading && _entries.isEmpty) { return const Center(child: CircularProgressIndicator()); @@ -476,50 +574,95 @@ class _DeviceFileTile extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final details = [ - if (entry.sizeBytes != null) formatFileSize(entry.sizeBytes!), - if (entry.modified != null) entry.modified!, - ]; + final detailStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant); - return ListTile( - enabled: enabled, + final disabledColor = colorScheme.onSurface.withValues(alpha: 0.38); + + // Laid out as an explicit Row rather than a ListTile so the columns line + // up with _buildSortHeader by construction instead of depending on + // ListTile's internal leading/gap geometry. + return InkWell( onTap: enabled ? onOpen : null, - leading: Icon( - entry.isDirectory - ? Icons.folder - : entry.isSymlink - ? Icons.link - : Icons.insert_drive_file_outlined, - color: entry.isDirectory ? colorScheme.secondary : null, - ), - title: Text(entry.name, overflow: TextOverflow.ellipsis), - subtitle: details.isEmpty - ? null - : Text( - details.join(' · '), - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - trailing: Wrap( - spacing: 4, - children: [ - IconButton( - tooltip: "Download to save location", - onPressed: enabled ? onDownload : null, - icon: const Icon(Icons.download), - ), - IconButton( - tooltip: "Rename", - onPressed: enabled ? onRename : null, - icon: const Icon(Icons.drive_file_rename_outline), - ), - IconButton( - tooltip: "Delete", - onPressed: enabled ? onDelete : null, - color: AppColors.danger, - icon: const Icon(Icons.delete_outline), + child: Padding( + padding: _rowPadding, + child: SizedBox( + height: 48, + child: Row( + children: [ + SizedBox( + width: _leadingColumnWidth, + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + entry.isDirectory + ? Icons.folder + : entry.isSymlink + ? Icons.link + : Icons.insert_drive_file_outlined, + color: !enabled + ? disabledColor + : entry.isDirectory + ? colorScheme.secondary + : null, + ), + ), + ), + Expanded( + child: Text( + entry.name, + overflow: TextOverflow.ellipsis, + style: enabled ? null : TextStyle(color: disabledColor), + ), + ), + SizedBox( + width: _sizeColumnWidth, + child: Text( + entry.sizeBytes == null + ? '' + : formatFileSize(entry.sizeBytes!), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + style: detailStyle, + ), + ), + const SizedBox(width: _columnGap), + SizedBox( + width: _modifiedColumnWidth, + child: Text( + entry.modified ?? '', + overflow: TextOverflow.ellipsis, + style: detailStyle, + ), + ), + SizedBox( + width: _actionsColumnWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: "Download to save location", + onPressed: enabled ? onDownload : null, + icon: const Icon(Icons.download), + ), + IconButton( + tooltip: "Rename", + onPressed: enabled ? onRename : null, + icon: const Icon(Icons.drive_file_rename_outline), + ), + IconButton( + tooltip: "Delete", + onPressed: enabled ? onDelete : null, + color: AppColors.danger, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ], ), - ], + ), ), ); } diff --git a/test/device_file_parser_test.dart b/test/device_file_parser_test.dart index e7ed830..9697d03 100644 --- a/test/device_file_parser_test.dart +++ b/test/device_file_parser_test.dart @@ -63,6 +63,144 @@ drwxrwx--x root sdcard_rw 2016-05-01 12:00 DCIM }); }); + group('sortDeviceFiles', () { + List sample() => [ + const DeviceFileEntry( + name: 'beta.txt', + path: '/sdcard/beta.txt', + isDirectory: false, + sizeBytes: 300, + modified: '2024-03-01 10:00', + ), + const DeviceFileEntry( + name: 'Alpha.txt', + path: '/sdcard/Alpha.txt', + isDirectory: false, + sizeBytes: 1000, + modified: '2024-01-01 10:00', + ), + const DeviceFileEntry( + name: 'gamma.txt', + path: '/sdcard/gamma.txt', + isDirectory: false, + sizeBytes: 20, + modified: '2024-02-01 10:00', + ), + const DeviceFileEntry( + name: 'Zips', + path: '/sdcard/Zips', + isDirectory: true, + modified: '2024-05-01 10:00', + ), + const DeviceFileEntry( + name: 'Docs', + path: '/sdcard/Docs', + isDirectory: true, + modified: '2023-01-01 10:00', + ), + ]; + + test('name order keeps directories first in both directions', () { + final asc = sample(); + sortDeviceFiles(asc); + expect(asc.map((e) => e.name), [ + 'Docs', + 'Zips', + 'Alpha.txt', + 'beta.txt', + 'gamma.txt', + ]); + + final desc = sample(); + sortDeviceFiles(desc, ascending: false); + expect(desc.map((e) => e.name), [ + 'Zips', + 'Docs', + 'gamma.txt', + 'beta.txt', + 'Alpha.txt', + ]); + }); + + test('modified order uses the timestamp', () { + final asc = sample(); + sortDeviceFiles(asc, sort: DeviceFileSort.modified); + expect(asc.map((e) => e.name), [ + 'Docs', + 'Zips', + 'Alpha.txt', + 'gamma.txt', + 'beta.txt', + ]); + + final desc = sample(); + sortDeviceFiles(desc, sort: DeviceFileSort.modified, ascending: false); + expect(desc.map((e) => e.name), [ + 'Zips', + 'Docs', + 'beta.txt', + 'gamma.txt', + 'Alpha.txt', + ]); + }); + + test( + 'size descending puts the largest file first, folders still on top', + () { + final entries = sample(); + sortDeviceFiles(entries, sort: DeviceFileSort.size, ascending: false); + expect(entries.map((e) => e.name), [ + 'Docs', + 'Zips', + 'Alpha.txt', + 'beta.txt', + 'gamma.txt', + ]); + expect(entries.take(2).every((e) => e.isDirectory), isTrue); + }, + ); + + test('missing size and timestamp fall back to name order', () { + final entries = [ + const DeviceFileEntry( + name: 'b.txt', + path: '/sdcard/b.txt', + isDirectory: false, + ), + const DeviceFileEntry( + name: 'a.txt', + path: '/sdcard/a.txt', + isDirectory: false, + ), + ]; + + sortDeviceFiles(entries, sort: DeviceFileSort.size); + expect(entries.map((e) => e.name), ['a.txt', 'b.txt']); + + sortDeviceFiles(entries, sort: DeviceFileSort.modified); + expect(entries.map((e) => e.name), ['a.txt', 'b.txt']); + }); + + test('unknown timestamps sort last when ascending', () { + final entries = [ + const DeviceFileEntry( + name: 'unknown.txt', + path: '/sdcard/unknown.txt', + isDirectory: false, + ), + const DeviceFileEntry( + name: 'dated.txt', + path: '/sdcard/dated.txt', + isDirectory: false, + modified: '2024-01-01 10:00', + ), + ]; + + sortDeviceFiles(entries, sort: DeviceFileSort.modified); + expect(entries.map((e) => e.name), ['dated.txt', 'unknown.txt']); + }); + }); + group('path helpers', () { test('quoteShellArg escapes embedded single quotes', () { expect(quoteShellArg("Rock 'n' Roll"), r"'Rock '\''n'\'' Roll'"); From dcd3eaa333ebed1d3d9b38d0bec3796f3708b62a Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:10:30 -0400 Subject: [PATCH 5/7] Add download destination control to the file manager Downloads previously always landed in the save location, which reset to the default folder on every launch and could only be changed from the main screen. - Persist the save location with shared_preferences so it survives restarts. A stored folder that is missing at launch falls back to the default for the session but is kept, since it may be on a drive or share that is not mounted yet. - Add an "Ask each time" option that prompts for a destination on every download, and a per-file "Download to..." action for one-off overrides, reachable from an overflow button or a right-click menu. - Show a destination bar in the file manager stating where the next download will land, with inline Change and Open folder actions. - "Open containing folder" after a download now opens the folder the file actually went to rather than the save location. - Use xdg-open on Linux; the opener previously fell back to macOS `open` on every non-Windows platform. --- lib/providers/app_provider.dart | 102 ++++++++-- lib/screens/home_screen.dart | 15 +- lib/widgets/file_manager_dialog.dart | 181 ++++++++++++++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 +++++++ pubspec.yaml | 1 + 6 files changed, 329 insertions(+), 36 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f0f8717..e759cc0 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path/path.dart' as p; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/adb_service.dart'; import '../services/config_service.dart'; @@ -57,6 +58,7 @@ class AppProvider with ChangeNotifier { Process? _recordingProcess; bool isRecording = false; String? _saveLocation; + bool _askEachDownload = false; bool isCheckingForUpdate = false; String? currentAppVersion; String? latestAppVersion; @@ -214,18 +216,58 @@ class AppProvider with ChangeNotifier { return 0; } + static const _kSaveLocation = 'save_location'; + static const _kAskEachDownload = 'ask_save_location_each_time'; + + /// Restores the save location chosen on a previous run, falling back to the + /// default folder when nothing is stored or the stored folder is gone. void _loadSaveLocation() async { + final prefs = await SharedPreferences.getInstance(); + _askEachDownload = prefs.getBool(_kAskEachDownload) ?? false; + + final stored = prefs.getString(_kSaveLocation); + if (stored != null && stored.isNotEmpty) { + if (await Directory(stored).exists()) { + _saveLocation = stored; + notifyListeners(); + return; + } + // Keep the preference: the folder may live on a drive or share that is + // simply not mounted right now, and should come back on a later launch. + _onLog( + "Save folder $stored is unavailable; using the default for now.", + 'error', + ); + } + + _saveLocation = await _defaultSaveLocation(); + notifyListeners(); + } + + Future _defaultSaveLocation() async { final docDir = await getApplicationDocumentsDirectory(); - _saveLocation = p.join(docDir.path, "OpenPelo"); - final dir = Directory(_saveLocation!); + final path = p.join(docDir.path, "OpenPelo"); + final dir = Directory(path); if (!await dir.exists()) { await dir.create(recursive: true); } - notifyListeners(); + return path; } String get saveLocation => _saveLocation ?? ""; + /// When true, every file-manager download asks for a destination instead of + /// using [saveLocation]. + bool get askEachDownload => _askEachDownload; + + Future setAskEachDownload(bool value) async { + if (_askEachDownload == value) return; + _askEachDownload = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kAskEachDownload, value); + } + void _startHeartbeat() { _heartbeatTimer?.cancel(); _heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (timer) { @@ -848,12 +890,27 @@ class AppProvider with ChangeNotifier { return await _adbService.pressKey(selectedDevice!.serial, 187); } - Future chooseSaveLocation() async { - String? selectedDirectory = await FilePicker.getDirectoryPath(); - if (selectedDirectory != null) { - _saveLocation = selectedDirectory; - notifyListeners(); + /// Prompts for a folder, seeded with the current save location. Returns null + /// when the user cancels. Does not change the saved default. + Future pickDownloadDirectory() async { + final selected = await FilePicker.getDirectoryPath( + initialDirectory: saveLocation.isEmpty ? null : saveLocation, + ); + if (selected == null) return null; + final dir = Directory(selected); + if (!await dir.exists()) { + await dir.create(recursive: true); } + return selected; + } + + Future chooseSaveLocation() async { + final selected = await pickDownloadDirectory(); + if (selected == null) return; + _saveLocation = selected; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kSaveLocation, selected); } Future>> loadGuide(String filename) async { @@ -1338,16 +1395,19 @@ class AppProvider with ChangeNotifier { return success; } - /// Pulls [entry] into the save location. Returns the local path on success. + /// Pulls [entry] into [destination], or the save location when omitted. + /// Returns the local path on success. Future downloadDeviceEntry( String deviceSerial, - DeviceFileEntry entry, - ) async { - if (saveLocation.isEmpty) return null; + DeviceFileEntry entry, { + String? destination, + }) async { + final root = destination ?? saveLocation; + if (root.isEmpty) return null; _setBusy(true); try { final localPath = _uniqueLocalPath( - safeLocalEntryPath(saveLocation, entry.name), + safeLocalEntryPath(root, entry.name), ); await _adbService.pullEntry(deviceSerial, entry.path, localPath); _onLog("Downloaded ${entry.name} to $localPath", 'info'); @@ -1420,12 +1480,16 @@ class AppProvider with ChangeNotifier { } } - void openSaveLocation() { - if (_saveLocation != null) { - // use url_launcher or process to open folder - // url_launcher 'file:$path' works on some OS - Process.run(Platform.isWindows ? 'explorer' : 'open', [_saveLocation!]); - } + /// Reveals [path] in the host file browser, defaulting to the save location. + void openSaveLocation({String? path}) { + final target = path ?? _saveLocation; + if (target == null || target.isEmpty) return; + final opener = Platform.isWindows + ? 'explorer' + : Platform.isMacOS + ? 'open' + : 'xdg-open'; + Process.run(opener, [target]); } @override diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 47404a4..3070dbf 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -437,12 +437,12 @@ class HomeScreen extends StatelessWidget { const _SectionHeader( icon: Icons.perm_media_outlined, - label: "Media Settings", + label: "Downloads & Media", ), const SizedBox(height: 6), Row( children: [ - const Text("Save Location: "), + const Text("Save folder: "), Expanded( child: Container( padding: const EdgeInsets.symmetric( @@ -454,15 +454,18 @@ class HomeScreen extends StatelessWidget { border: Border.all(color: colorScheme.outline), borderRadius: BorderRadius.circular(8), ), - child: Text( - provider.saveLocation, - overflow: TextOverflow.ellipsis, + child: Tooltip( + message: provider.saveLocation, + child: Text( + provider.saveLocation, + overflow: TextOverflow.ellipsis, + ), ), ), ), IconButton( icon: const Icon(Icons.folder), - onPressed: provider.openSaveLocation, + onPressed: () => provider.openSaveLocation(), tooltip: "Open Folder", ), IconButton( diff --git a/lib/widgets/file_manager_dialog.dart b/lib/widgets/file_manager_dialog.dart index ba2929b..7ee7d3d 100644 --- a/lib/widgets/file_manager_dialog.dart +++ b/lib/widgets/file_manager_dialog.dart @@ -14,7 +14,7 @@ const double _sizeColumnWidth = 90; const double _columnGap = 20; // Fits 'YYYY-MM-DD HH:MM:SS', the longest form the ls parser produces. const double _modifiedColumnWidth = 150; -const double _actionsColumnWidth = 144; // three IconButtons +const double _actionsColumnWidth = 192; // three IconButtons + overflow menu const double _arrowSlotWidth = 18; class FileManagerDialog extends StatefulWidget { @@ -49,6 +49,9 @@ class _FileManagerDialogState extends State { String? _statusMessage; bool _statusIsError = false; String? _lastDownloadPath; + // Folder the last download actually landed in, which may be a one-off + // override rather than the save location. + String? _lastDownloadDir; @override void initState() { @@ -140,6 +143,7 @@ class _FileManagerDialogState extends State { _transferLabel = "Waiting for file selection..."; _transferProgress = null; _lastDownloadPath = null; + _lastDownloadDir = null; }); final count = await _provider.uploadFilesToDevice( widget.deviceSerial, @@ -169,29 +173,37 @@ class _FileManagerDialogState extends State { await _refresh(); } - Future _download(DeviceFileEntry entry) async { - if (_provider.saveLocation.isEmpty) { - _setStatus( - "Choose a save location on the main screen first.", - isError: true, - ); - return; + /// Downloads [entry] to the save location, or to a folder the user picks when + /// [chooseDestination] is set or "Ask each time" is on. + Future _download( + DeviceFileEntry entry, { + bool chooseDestination = false, + }) async { + String? destination; + if (chooseDestination || _provider.askEachDownload) { + destination = await _provider.pickDownloadDirectory(); + // Cancelling the picker is not an error; leave the status bar alone. + if (destination == null || !mounted) return; } + final target = destination ?? _provider.saveLocation; setState(() { _transferring = true; _transferLabel = "Downloading ${entry.name}..."; _transferProgress = null; _lastDownloadPath = null; + _lastDownloadDir = null; }); final path = await _provider.downloadDeviceEntry( widget.deviceSerial, entry, + destination: destination, ); if (!mounted) return; setState(() { _transferring = false; _transferLabel = null; _lastDownloadPath = path; + _lastDownloadDir = path == null ? null : target; }); _setStatus( path == null ? "Could not download ${entry.name}" : "Saved to $path", @@ -398,6 +410,7 @@ class _FileManagerDialogState extends State { ), _buildSortHeader(), Expanded(child: _buildFileList()), + _buildDestinationBar(), ], ), ); @@ -489,6 +502,7 @@ class _FileManagerDialogState extends State { ? () => _navigateTo(entry.path) : null, onDownload: () => _download(entry), + onDownloadTo: () => _download(entry, chooseDestination: true), onRename: () => _rename(entry), onDelete: () => _confirmDelete(entry), ); @@ -496,6 +510,80 @@ class _FileManagerDialogState extends State { ); } + /// States the download destination *before* a transfer happens, and lets the + /// user retarget it without leaving the dialog. + Widget _buildDestinationBar() { + final colorScheme = Theme.of(context).colorScheme; + + return Consumer( + builder: (context, provider, _) { + final destination = provider.saveLocation; + // With "Ask each time" on, this folder is only the picker's starting + // point, so it is labelled and styled as a default rather than as the + // place the next download will land. + final asking = provider.askEachDownload; + return Container( + padding: const EdgeInsets.fromLTRB(16, 6, 8, 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Row( + children: [ + Text(asking ? "Default folder:" : "Save to:"), + const SizedBox(width: 8), + Expanded( + child: Tooltip( + message: destination, + child: Text( + destination, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: asking ? FontWeight.normal : FontWeight.w600, + color: asking ? colorScheme.onSurfaceVariant : null, + ), + ), + ), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _transferring ? null : provider.chooseSaveLocation, + icon: const Icon(Icons.edit, size: 16), + label: const Text("Change"), + ), + IconButton( + tooltip: "Open save folder", + onPressed: destination.isEmpty + ? null + : () => provider.openSaveLocation(), + icon: const Icon(Icons.folder_open, size: 18), + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 8), + Tooltip( + message: "Pick a folder for every download", + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: provider.askEachDownload, + visualDensity: VisualDensity.compact, + onChanged: _transferring + ? null + : (value) => + provider.setAskEachDownload(value ?? false), + ), + const Text("Ask each time"), + ], + ), + ), + ], + ), + ); + }, + ); + } + Widget _buildStatusBar() { final colorScheme = Theme.of(context).colorScheme; final textStyle = Theme.of(context).textTheme.bodySmall; @@ -531,8 +619,9 @@ class _FileManagerDialogState extends State { ), if (_lastDownloadPath != null && _statusMessage != null) IconButton( - tooltip: "Open save folder", - onPressed: _provider.openSaveLocation, + tooltip: "Open containing folder", + onPressed: () => + _provider.openSaveLocation(path: _lastDownloadDir), icon: const Icon(Icons.folder, size: 18), visualDensity: VisualDensity.compact, ), @@ -542,6 +631,7 @@ class _FileManagerDialogState extends State { onPressed: () => setState(() { _statusMessage = null; _lastDownloadPath = null; + _lastDownloadDir = null; }), icon: const Icon(Icons.close, size: 18), visualDensity: VisualDensity.compact, @@ -559,6 +649,7 @@ class _DeviceFileTile extends StatelessWidget { final bool enabled; final VoidCallback? onOpen; final VoidCallback onDownload; + final VoidCallback onDownloadTo; final VoidCallback onRename; final VoidCallback onDelete; @@ -567,10 +658,68 @@ class _DeviceFileTile extends StatelessWidget { required this.enabled, required this.onOpen, required this.onDownload, + required this.onDownloadTo, required this.onRename, required this.onDelete, }); + List> _menuItems() => [ + PopupMenuItem( + value: onDownload, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.download), + title: Text("Download"), + ), + ), + PopupMenuItem( + value: onDownloadTo, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.drive_folder_upload_outlined), + title: Text("Download to..."), + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: onRename, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.drive_file_rename_outline), + title: Text("Rename"), + ), + ), + PopupMenuItem( + value: onDelete, + child: const ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.delete_outline, color: AppColors.danger), + title: Text("Delete"), + ), + ), + ]; + + /// Explorer-style right-click menu, mirroring the overflow button so the + /// per-file destination override is reachable either way. + Future _showContextMenu(BuildContext context, Offset position) async { + final overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (overlay == null) return; + final selected = await showMenu( + context: context, + position: RelativeRect.fromRect( + position & Size.zero, + Offset.zero & overlay.size, + ), + items: _menuItems(), + ); + selected?.call(); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -585,6 +734,9 @@ class _DeviceFileTile extends StatelessWidget { // ListTile's internal leading/gap geometry. return InkWell( onTap: enabled ? onOpen : null, + onSecondaryTapUp: enabled + ? (details) => _showContextMenu(context, details.globalPosition) + : null, child: Padding( padding: _rowPadding, child: SizedBox( @@ -642,7 +794,7 @@ class _DeviceFileTile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - tooltip: "Download to save location", + tooltip: "Download", onPressed: enabled ? onDownload : null, icon: const Icon(Icons.download), ), @@ -657,6 +809,13 @@ class _DeviceFileTile extends StatelessWidget { color: AppColors.danger, icon: const Icon(Icons.delete_outline), ), + PopupMenuButton( + tooltip: "More actions", + enabled: enabled, + itemBuilder: (_) => _menuItems(), + onSelected: (action) => action(), + icon: const Icon(Icons.more_vert), + ), ], ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 615dfdc..10939b0 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_picker_darwin import network_info_plus import package_info_plus import screen_retriever_macos +import shared_preferences_foundation import url_launcher_macos import window_manager @@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 7356c9f..4b454cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" file_picker: dependency: "direct main" description: @@ -601,6 +609,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index b9eface..7d8a7aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.6 provider: ^6.1.5+1 + shared_preferences: ^2.5.5 url_launcher: ^6.3.2 window_manager: ^0.5.2 From 494384a808cd4e31b07e84e0c14848954b2dc33f Mon Sep 17 00:00:00 2001 From: eMadman <24726844+eMadman@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:10:34 -0400 Subject: [PATCH 6/7] Apply dart format to fix CI validate step --- lib/providers/app_provider.dart | 4 +--- lib/screens/home_screen.dart | 5 ++--- lib/services/adb_service.dart | 4 +++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index e759cc0..afd3d6e 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1406,9 +1406,7 @@ class AppProvider with ChangeNotifier { if (root.isEmpty) return null; _setBusy(true); try { - final localPath = _uniqueLocalPath( - safeLocalEntryPath(root, entry.name), - ); + final localPath = _uniqueLocalPath(safeLocalEntryPath(root, entry.name)); await _adbService.pullEntry(deviceSerial, entry.path, localPath); _onLog("Downloaded ${entry.name} to $localPath", 'info'); return localPath; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3070dbf..9e80da3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -89,9 +89,8 @@ class HomeScreen extends StatelessWidget { } else { showDialog( context: context, - builder: (_) => FileManagerDialog( - deviceSerial: deviceSerial, - ), + builder: (_) => + FileManagerDialog(deviceSerial: deviceSerial), ); } } else if (value == 'dev_options') { diff --git a/lib/services/adb_service.dart b/lib/services/adb_service.dart index 58054e3..a5c68ac 100644 --- a/lib/services/adb_service.dart +++ b/lib/services/adb_service.dart @@ -1660,7 +1660,9 @@ class AdbService { remotePath, ); if (renameResult == _RenameEntryResult.destinationExists) { - throw Exception('A file named ${p.basename(localPath)} already exists.'); + throw Exception( + 'A file named ${p.basename(localPath)} already exists.', + ); } if (renameResult == _RenameEntryResult.failed) { throw Exception('Could not finish uploading ${p.basename(localPath)}.'); From de8608d645d5470e638f2cdaf96aec8c67bc2b02 Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 14 Sep 2026 20:09:33 -0500 Subject: [PATCH 7/7] port 5555 automatic reconnect. --- README.md | 28 ++ lib/models/device_model.dart | 33 +- lib/providers/app_provider.dart | 84 ++++- lib/screens/home_screen.dart | 1 + lib/services/adb_service.dart | 115 +++++- lib/services/wireless_connection_manager.dart | 222 +++++++++++ test/adb_service_test.dart | 18 + test/app_provider_connection_test.dart | 85 +++++ test/wireless_connection_manager_test.dart | 348 ++++++++++++++++++ 9 files changed, 904 insertions(+), 30 deletions(-) create mode 100644 lib/services/wireless_connection_manager.dart create mode 100644 test/app_provider_connection_test.dart create mode 100644 test/wireless_connection_manager_test.dart diff --git a/README.md b/README.md index 4c39979..3751401 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,34 @@ normally require a pairing port and six-digit pairing code before connecting. The desktop builds support `adb pair`; the experimental mobile ADB client does not currently support pairing a new device. +### Automatic WiFi connection + +Desktop builds always maintain wireless ADB connections automatically. After +you connect an Android device wirelessly once, OpenPelo verifies its manufacturer +and hardware serial, enables legacy ADB on port 5555, and remembers its address. +Setup may briefly interrupt the original connection. This works with any +manufacturer whose firmware supports legacy TCP/IP ADB and reports a usable +hardware serial. Devices connected only by USB are not switched automatically. + +While OpenPelo is running, it retries remembered devices when disconnected, +including after reopening the app. This can reconnect even when the tablet's +**Wireless debugging** toggle is off. Retry delays increase from 10 seconds to +five minutes when a device is unavailable. When both wireless connections are +present, OpenPelo shows the port-5555 connection. Peloton devices appear at the +top of the device list; all other devices remain available, and your selected +target is preserved. It verifies the manufacturer and hardware serial +before using a remembered address, so a reassigned IP cannot silently select a +different device. + +Legacy ADB is **unencrypted**: use this feature only on a trusted local network. +Rebooting the tablet may +remove the listener. If that happens, or its IP changes while disconnected, +connect normally again so OpenPelo can set it up and remember the new address. + +Tested on a Peloton PLTN-RB1VO-2 running Android 11: port 5555 accepted a fresh +connection with Wireless debugging off. Survival of the reported approximately +24-hour automatic shutdown has not yet been verified. + ## Build from source ### Requirements diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 377a5b5..b5a3b64 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + class DeviceModel { final String serial; final String status; @@ -6,6 +8,8 @@ class DeviceModel { final String? port; final String? name; // manufacturer + model final String? abi; // arm64-v8a or armeabi-v7a + final String? hardwareSerial; + final String? manufacturer; DeviceModel({ required this.serial, @@ -15,12 +19,24 @@ class DeviceModel { this.port, this.name, this.abi, + this.hardwareSerial, + this.manufacturer, }); + String? get identityKey => manufacturer == null || hardwareSerial == null + ? null + : jsonEncode([manufacturer, hardwareSerial]); + + bool get isPeloton => RegExp( + r'^peloton(?:\s|$)', + caseSensitive: false, + ).hasMatch(manufacturer ?? name ?? ''); + String get displayName { if (transport == 'wifi' && ip != null) { return "$name • WiFi ($ip${port != null ? ':$port' : ''})"; } + if (transport == 'wifi') return "$name • WiFi"; return "$name • USB"; } @@ -34,10 +50,21 @@ class DeviceModel { ip == other.ip && port == other.port && name == other.name && - abi == other.abi; + abi == other.abi && + hardwareSerial == other.hardwareSerial && + manufacturer == other.manufacturer; } @override - int get hashCode => - Object.hash(serial, status, transport, ip, port, name, abi); + int get hashCode => Object.hash( + serial, + status, + transport, + ip, + port, + name, + abi, + hardwareSerial, + manufacturer, + ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index afd3d6e..8ca2f2f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -12,6 +12,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/adb_service.dart'; +import '../services/wireless_connection_manager.dart'; import '../services/config_service.dart'; import '../services/device_file_parser.dart'; import '../services/release_asset_selector.dart'; @@ -52,7 +53,12 @@ class AppProvider with ChangeNotifier { Map availableApps = {}; DeviceModel? selectedDevice; String statusMessage = "Checking device connection..."; - bool isBusy = false; + bool _operationBusy = false; + bool _maintainingWireless = false; + bool get isBusy => _operationBusy || _maintainingWireless; + late final WirelessConnectionManager _wirelessConnections; + String? _selectedDeviceIdentity; + bool _disposed = false; Timer? _heartbeatTimer; bool _isCheckingDevices = false; Process? _recordingProcess; @@ -65,15 +71,25 @@ class AppProvider with ChangeNotifier { Uri latestReleasePageUrl = _openpeloReleasesPageUri; String? updateCheckError; - AppProvider() : _adbService = AdbService(onLog: (m, t) {}) { - // Re-initialize AdbService with actual log handler - // But we need 'this' which we can't use in initializer. - // So we use a wrapper or init method. + AppProvider({AdbService? adbService}) + : _adbService = adbService ?? AdbService(onLog: (m, t) {}) { + _wirelessConnections = WirelessConnectionManager( + adb: _adbService, + save: (data) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('wireless_adb_addresses', data); + }, + onBusy: (value) { + _maintainingWireless = value; + if (!_disposed) notifyListeners(); + }, + ); } void init() { _adbService.onLog = _onLog; - _adbService.init().then((_) { + _initConnections().then((_) { + if (_disposed) return; _startHeartbeat(); _checkDevices(); }); @@ -82,9 +98,16 @@ class AppProvider with ChangeNotifier { checkForUpdates(); } + Future _initConnections() async { + final prefs = await SharedPreferences.getInstance(); + _wirelessConnections.restore(prefs.getString('wireless_adb_addresses')); + await _adbService.init(); + } + static const int _maxLogLines = 300; void _onLog(String message, String tag) { + if (_disposed) return; final time = DateFormat('HH:mm:ss').format(DateTime.now()); logs.add(LogEntry('[$time]', message, tag)); if (logs.length > _maxLogLines) { @@ -94,7 +117,7 @@ class AppProvider with ChangeNotifier { } void _setBusy(bool value) { - isBusy = value; + _operationBusy = value; notifyListeners(); } @@ -278,10 +301,18 @@ class AppProvider with ChangeNotifier { } Future _checkDevices({bool silent = false}) async { - if (_isCheckingDevices) return; + if (_isCheckingDevices || _disposed) return; _isCheckingDevices = true; try { - final newDevices = await _adbService.getConnectedDevices(); + var detected = await _adbService.getConnectedDevices(); + if (_disposed) return; + if (!isBusy && !isRecording) { + if (await _wirelessConnections.maintain(detected)) { + detected = await _adbService.getConnectedDevices(); + } + } + if (_disposed) return; + final newDevices = prioritizeDeviceConnections(detected); if (!listEquals(newDevices, devices)) { devices = newDevices; @@ -293,12 +324,25 @@ class AppProvider with ChangeNotifier { } else { // Select first if none selected or previous selection gone if (selectedDevice == null || - !devices.any((d) => d.serial == selectedDevice!.serial)) { - // Prefer wifi - selectedDevice = devices.firstWhere( - (d) => d.transport == 'wifi', - orElse: () => devices.first, + !devices.any( + (d) => + d.serial == selectedDevice!.serial && + (_selectedDeviceIdentity == null || + d.identityKey == _selectedDeviceIdentity), + )) { + final sameDevice = devices.where( + (d) => + d.identityKey == _selectedDeviceIdentity && + _selectedDeviceIdentity != null, ); + if (_selectedDeviceIdentity != null) { + selectedDevice = sameDevice.isEmpty ? null : sameDevice.first; + } else { + selectedDevice = devices.firstWhere( + (d) => d.isPeloton, + orElse: () => devices.first, + ); + } } else { // Update selected device info selectedDevice = devices.firstWhere( @@ -306,7 +350,12 @@ class AppProvider with ChangeNotifier { ); } - statusMessage = "✅ Connected to ${selectedDevice!.displayName}"; + _selectedDeviceIdentity = + selectedDevice?.identityKey ?? _selectedDeviceIdentity; + statusMessage = selectedDevice == null + ? "Waiting for the selected device. You can select another device." + : "✅ Connected to ${selectedDevice!.displayName}"; + if (selectedDevice == null) availableApps = {}; if (!silent) _onLog(statusMessage, 'status'); _loadApps(); } @@ -321,13 +370,14 @@ class AppProvider with ChangeNotifier { if (selectedDevice == null) return; final targetSerial = selectedDevice!.serial; final apps = await _configService.loadApps(selectedDevice!.abi); - if (selectedDevice?.serial != targetSerial) return; + if (_disposed || selectedDevice?.serial != targetSerial) return; availableApps = apps; notifyListeners(); } void selectDevice(DeviceModel device) { selectedDevice = device; + _selectedDeviceIdentity = device.identityKey; statusMessage = "✅ Connected to ${device.displayName}"; _loadApps(); notifyListeners(); @@ -1492,6 +1542,8 @@ class AppProvider with ChangeNotifier { @override void dispose() { + _disposed = true; + _wirelessConnections.dispose(); _heartbeatTimer?.cancel(); _recordingProcess?.kill(); super.dispose(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 9e80da3..3c83b43 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -341,6 +341,7 @@ class HomeScreen extends StatelessWidget { const SizedBox(height: 10), if (provider.devices.isNotEmpty) DropdownButtonFormField( + key: ValueKey(provider.selectedDevice?.serial), initialValue: provider.selectedDevice?.serial, decoration: const InputDecoration( labelText: "Target Device", diff --git a/lib/services/adb_service.dart b/lib/services/adb_service.dart index a5c68ac..26d47b2 100644 --- a/lib/services/adb_service.dart +++ b/lib/services/adb_service.dart @@ -24,7 +24,8 @@ class AdbService { String? _connectedIp; int _connectedPort = 5555; - AdbService({required this.onLog}); + AdbService({required this.onLog, String? executablePath}) + : _adbPath = executablePath; bool get isMobile => Platform.isAndroid || Platform.isIOS; @@ -129,6 +130,7 @@ class AdbService { Future runAdbCommand( List args, { bool allowFailure = false, + Duration? timeout, }) async { if (isMobile) { // Mobile stub for unsupported methods called via raw runAdb @@ -145,7 +147,9 @@ class AdbService { onLog("\$ adb ${_argsForLog(args).join(' ')}", 'command'); try { - final result = await Process.run(_adbPath!, args); + final result = timeout == null + ? await Process.run(_adbPath!, args) + : await _runTimedCommand(args, timeout); if (result.stdout.toString().isNotEmpty) { // Filter out empty lines final lines = result.stdout.toString().trim(); @@ -164,6 +168,25 @@ class AdbService { } } + Future _runTimedCommand( + List args, + Duration timeout, + ) async { + final process = await Process.start(_adbPath!, args); + try { + final output = await Future.wait([ + process.exitCode, + process.stdout.transform(utf8.decoder).join(), + process.stderr.transform(utf8.decoder).join(), + ]).timeout(timeout); + return ProcessResult(process.pid, output[0] as int, output[1], output[2]); + } on TimeoutException { + // Kill this client command only, never the shared ADB server. + process.kill(); + rethrow; + } + } + List _argsForLog(List args) { if (args.isNotEmpty && args.first == 'pair' && args.length >= 3) { return [...args.take(args.length - 1), '']; @@ -218,15 +241,16 @@ class AdbService { final line = lines[i].trim(); if (line.isEmpty) continue; - final parts = line.split(RegExp(r'\s+')); - if (parts.length < 2) continue; - - final serial = parts[0]; - final status = parts[1]; + final entry = parseAdbDeviceEntry(line); + if (entry == null) continue; + final serial = entry.$1; + final status = entry.$2; if (status != 'device') continue; - String transport = 'usb'; + String transport = serial.contains('._adb-tls-connect._tcp') + ? 'wifi' + : 'usb'; String? ip; String? port; @@ -240,6 +264,7 @@ class AdbService { // Get Name String? name = await getDeviceName(serial); String? abi = await getDeviceAbi(serial); + final identity = await getDeviceIdentity(serial); devices.add( DeviceModel( @@ -250,6 +275,8 @@ class AdbService { port: port, name: name, abi: abi, + hardwareSerial: identity?.$2, + manufacturer: identity?.$1, ), ); } @@ -493,7 +520,65 @@ class AdbService { Future connectTcpIp(String serial) async { if (isMobile) return; // Cannot switch mode from mobile client - await runAdbCommand(['-s', serial, 'tcpip', '5555']); + await runAdbCommand([ + '-s', + serial, + 'tcpip', + '5555', + ], timeout: const Duration(seconds: 8)); + } + + Future<(String, String)?> getDeviceIdentity(String serial) async { + try { + final result = await runAdbCommand( + [ + '-s', + serial, + 'shell', + 'getprop ro.product.manufacturer; getprop ro.serialno', + ], + allowFailure: true, + timeout: const Duration(seconds: 5), + ); + final lines = result.stdout.toString().trim().split(RegExp(r'\r?\n')); + if (result.exitCode != 0 || lines.length != 2) return null; + final manufacturer = lines[0].trim(); + final hardwareSerial = lines[1].trim(); + if (manufacturer.isEmpty || + hardwareSerial.isEmpty || + hardwareSerial.toLowerCase() == 'unknown') { + return null; + } + return (manufacturer, hardwareSerial); + } catch (_) { + return null; + } + } + + /// Verify identity before changing a device or using a remembered address. + Future isExpectedDevice(String serial, String identityKey) async { + final identity = await getDeviceIdentity(serial); + return identity != null && + jsonEncode([identity.$1, identity.$2]) == identityKey; + } + + Future connectLegacyWifi(String ip) async { + final result = await runAdbCommand([ + 'connect', + '$ip:5555', + ], timeout: const Duration(seconds: 5)); + final output = '${result.stdout}\n${result.stderr}'.toLowerCase(); + if (!output.contains('connected to $ip:5555')) { + throw AdbCommandException(['connect', '$ip:5555'], result); + } + } + + Future disconnectEndpoint(String endpoint) async { + await runAdbCommand( + ['disconnect', endpoint], + allowFailure: true, + timeout: const Duration(seconds: 5), + ); } /// Enable wireless debugging quick settings tile on the device. @@ -1240,7 +1325,7 @@ class AdbService { 'shell', 'ip', 'route', - ]); + ], timeout: const Duration(seconds: 5)); final output = result.stdout.toString(); // Look for "src " in the output of `ip route` final match = RegExp(r'src\s+(\d+\.\d+\.\d+\.\d+)').firstMatch(output); @@ -1255,7 +1340,7 @@ class AdbService { 'addr', 'show', 'wlan0', - ]); + ], timeout: const Duration(seconds: 5)); final output2 = result2.stdout.toString(); final match2 = RegExp(r'inet\s+(\d+\.\d+\.\d+\.\d+)').firstMatch(output2); if (match2 != null) return match2.group(1); @@ -1815,6 +1900,14 @@ class AdbService { enum _RenameEntryResult { renamed, destinationExists, failed } +/// mDNS instance names can contain spaces, for example a collision suffix (2). +(String, String)? parseAdbDeviceEntry(String line) { + final match = RegExp( + r'^(.+?)\s+(device|offline|unauthorized)(?:\s|$)', + ).firstMatch(line.trim()); + return match == null ? null : (match.group(1)!, match.group(2)!); +} + String? parsePlatformToolsRevision(String sourceProperties) { for (final line in const LineSplitter().convert(sourceProperties)) { final match = RegExp(r'^Pkg\.Revision\s*=\s*(\S+)\s*$').firstMatch(line); diff --git a/lib/services/wireless_connection_manager.dart b/lib/services/wireless_connection_manager.dart new file mode 100644 index 0000000..2061c3a --- /dev/null +++ b/lib/services/wireless_connection_manager.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../models/device_model.dart'; +import 'adb_service.dart'; + +/// Remembers verified devices, never arbitrary addresses found on the network. +class WirelessConnectionManager { + final AdbService adb; + final Future Function(String) save; + final void Function(bool) onBusy; + final DateTime Function() now; + final Future Function(Duration) delay; + final Map _addresses = {}; + final Map _retryAt = {}; + final Map _failures = {}; + bool _running = false; + bool _disposed = false; + + WirelessConnectionManager({ + required this.adb, + required this.save, + required this.onBusy, + DateTime Function()? now, + Future Function(Duration)? delay, + }) : now = now ?? DateTime.now, + delay = delay ?? Future.delayed; + + void restore(String? data) { + if (data == null) return; + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return; + for (final entry in decoded.entries) { + if (_validIdentity(entry.key) && + entry.value is String && + _validIp(entry.value as String)) { + _addresses[entry.key] = entry.value as String; + } + } + } on FormatException { + // A damaged preference must not prevent normal USB or Wi-Fi connections. + } + } + + static bool _validIdentity(String value) { + try { + final parts = jsonDecode(value); + return parts is List && + parts.length == 2 && + parts.every((part) => part is String && part.trim().isNotEmpty) && + parts[1].toString().toLowerCase() != 'unknown'; + } on FormatException { + return false; + } + } + + static bool _validIp(String value) { + final address = InternetAddress.tryParse(value); + return address != null && + address.type == InternetAddressType.IPv4 && + !address.isLoopback && + value != '0.0.0.0' && + value != '255.255.255.255'; + } + + bool get _active => !_disposed; + + /// Runs at most one setup/reconnect per heartbeat. The caller skips this + /// during installs, recordings and other device operations. + Future maintain(List devices) async { + if (!_active || _running || adb.isMobile) return false; + _running = true; + try { + final candidates = {}; + for (final device in devices) { + final id = device.identityKey; + if (id == null || !_validIdentity(id) || device.transport != 'wifi') { + continue; + } + // Prefer the legacy endpoint if both TLS and legacy are advertised. + if (!candidates.containsKey(id) || device.port == '5555') { + candidates[id] = device; + } + } + final ids = {...candidates.keys, ..._addresses.keys}; + for (final id in ids) { + if (!_active) break; + final device = candidates[id]; + if (device?.port == '5555' && _addresses[id] == device?.ip) { + _failures.remove(id); + _retryAt.remove(id); + continue; + } + final retry = _retryAt[id]; + if (retry != null && now().isBefore(retry)) continue; + onBusy(true); + try { + return await _maintainDevice(id, device, devices); + } catch (error) { + final failures = (_failures[id] ?? 0) + 1; + _failures[id] = failures; + // 10, 20, 40, 80, 160, then 300 seconds; no overlapping retries. + final seconds = failures >= 6 ? 300 : 5 * (1 << failures); + _retryAt[id] = now().add(Duration(seconds: seconds)); + if (_active && failures == 1) { + adb.onLog( + 'WiFi connection unavailable; retrying automatically. ' + 'After a reboot, enable Wireless debugging once to set it up again. ' + '($error)', + 'info', + ); + } + } finally { + onBusy(false); + } + return false; + } + return false; + } finally { + _running = false; + } + } + + Future _maintainDevice( + String id, + DeviceModel? device, + List devices, + ) async { + String? ip = _addresses[id]; + if (device != null) { + if (!await adb.isExpectedDevice(device.serial, id)) { + throw StateError('Device identity could not be verified'); + } + if (!_active) return false; + // Resolve before tcpip restarts adbd and closes the original transport. + ip = device.ip ?? await adb.getDeviceIp(device.serial); + } + if (ip == null || !_validIp(ip)) { + throw StateError('No usable device WiFi address'); + } + if (!_active) return false; + final endpoint = '$ip:5555'; + // Do not interfere with another device already using a remembered IP. + if (devices.any((d) => d.serial == endpoint && d.identityKey != id)) { + throw StateError('The saved address belongs to another device'); + } + if (_addresses[id] != ip && device != null) { + _addresses[id] = ip; + await save(jsonEncode(_addresses)); + } + if (!_active) return false; + + if (device?.serial != endpoint) { + try { + await adb.connectLegacyWifi(ip); + } catch (_) { + // Without an existing authenticated connection we cannot re-enable + // the listener. Retry the saved endpoint later, without scanning. + if (device == null || !_active) rethrow; + if (!await adb.isExpectedDevice(device.serial, id)) { + throw StateError('Device identity changed before setup'); + } + if (!_active) return false; + adb.onLog('Enabling automatic WiFi connection at $endpoint.', 'info'); + await adb.connectTcpIp(device.serial); + await delay(const Duration(seconds: 2)); + if (!_active) return false; + await adb.connectLegacyWifi(ip); + } + } + // A DHCP address may now belong to a different authorized Android device. + try { + if (!await adb.isExpectedDevice(endpoint, id)) { + throw StateError( + 'The saved address did not identify the expected device', + ); + } + } catch (_) { + if (!devices.any((d) => d.serial == endpoint)) { + await adb.disconnectEndpoint(endpoint); + } + rethrow; + } + if (!_active) return false; + _failures.remove(id); + _retryAt.remove(id); + adb.onLog( + 'Connected at $endpoint. Automatic reconnection is ready.', + 'info', + ); + return true; + } + + void dispose() { + _disposed = true; + } +} + +/// Prefer port 5555 for each device, then put Pelotons first without filtering +/// out other manufacturers. Preserve relative order within each group. +List prioritizeDeviceConnections(List devices) { + final legacyIds = devices + .where( + (d) => + d.transport == 'wifi' && d.port == '5555' && d.identityKey != null, + ) + .map((d) => d.identityKey) + .toSet(); + final unique = devices + .where( + (d) => + d.transport != 'wifi' || + d.port == '5555' || + !legacyIds.contains(d.identityKey), + ) + .toList(); + return [ + ...unique.where((d) => d.isPeloton), + ...unique.where((d) => !d.isPeloton), + ]; +} diff --git a/test/adb_service_test.dart b/test/adb_service_test.dart index 25ebf67..d8619c6 100644 --- a/test/adb_service_test.dart +++ b/test/adb_service_test.dart @@ -2,6 +2,24 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openpelo/services/adb_service.dart'; void main() { + test( + 'parses mDNS names containing spaces and ignores device-list headers', + () { + expect(parseAdbDeviceEntry('adb-123 (2)._adb-tls-connect._tcp\tdevice'), ( + 'adb-123 (2)._adb-tls-connect._tcp', + 'device', + )); + expect(parseAdbDeviceEntry('192.168.1.172:5555\tdevice'), ( + '192.168.1.172:5555', + 'device', + )); + expect(parseAdbDeviceEntry('usb123\tunauthorized'), ( + 'usb123', + 'unauthorized', + )); + expect(parseAdbDeviceEntry('List of devices attached'), isNull); + }, + ); group('parsePlatformToolsRevision', () { test('reads the package revision', () { const properties = ''' diff --git a/test/app_provider_connection_test.dart b/test/app_provider_connection_test.dart new file mode 100644 index 0000000..874eca8 --- /dev/null +++ b/test/app_provider_connection_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openpelo/models/device_model.dart'; +import 'package:openpelo/providers/app_provider.dart'; +import 'package:openpelo/services/adb_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeviceListAdb extends AdbService { + List devices = []; + DeviceListAdb() : super(onLog: (_, _) {}); + @override + Future> getConnectedDevices() async => devices; + @override + Future isExpectedDevice(String serial, String identityKey) async => + devices.any((d) => d.serial == serial && d.identityKey == identityKey); +} + +DeviceModel device(String manufacturer, String id, String endpoint) => + DeviceModel( + serial: endpoint, + status: 'device', + transport: 'wifi', + ip: endpoint.split(':').first, + port: endpoint.split(':').last, + name: '$manufacturer tablet', + manufacturer: manufacturer, + hardwareSerial: id, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'Peloton sorts first without replacing a manually selected other device', + () async { + SharedPreferences.setMockInitialValues({}); + final adb = DeviceListAdb(); + final provider = AppProvider(adbService: adb); + addTearDown(provider.dispose); + final kindle = device('Amazon', 'kindle', '192.168.1.10:5555'); + final bike = device( + 'Peloton Interactive LLC', + 'bike', + '192.168.1.20:5555', + ); + adb.devices = [kindle]; + await provider.refresh(); + provider.selectDevice(kindle); + adb.devices = [kindle, bike]; + await provider.refresh(); + expect(provider.devices, [bike, kindle]); + expect(provider.selectedDevice, kindle); + }, + ); + + test( + 'reassigned endpoint cannot silently change the selected device', + () async { + SharedPreferences.setMockInitialValues({}); + final adb = DeviceListAdb(); + final provider = AppProvider(adbService: adb); + addTearDown(provider.dispose); + final bike = device( + 'Peloton Interactive LLC', + 'bike', + '192.168.1.20:5555', + ); + adb.devices = [bike]; + await provider.refresh(); + expect(provider.selectedDevice, bike); + final other = device('Amazon', 'kindle', bike.serial); + adb.devices = [other]; + await provider.refresh(); + expect(provider.selectedDevice, isNull); + expect(provider.devices, [other]); + final recovered = device( + 'Peloton Interactive LLC', + 'bike', + '192.168.1.30:5555', + ); + adb.devices = [other, recovered]; + await provider.refresh(); + expect(provider.selectedDevice, recovered); + }, + ); +} diff --git a/test/wireless_connection_manager_test.dart b/test/wireless_connection_manager_test.dart new file mode 100644 index 0000000..704368e --- /dev/null +++ b/test/wireless_connection_manager_test.dart @@ -0,0 +1,348 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openpelo/models/device_model.dart'; +import 'package:openpelo/services/adb_service.dart'; +import 'package:openpelo/services/wireless_connection_manager.dart'; + +const ip = '192.168.1.172'; +const endpoint = '$ip:5555'; +const hardwareId = 'PELOTON123'; +const identityKey = '["Peloton Interactive LLC","PELOTON123"]'; +const tlsSerial = 'adb-PELOTON123-test (2)._adb-tls-connect._tcp'; + +DeviceModel peloton({bool legacy = false, String address = ip}) => DeviceModel( + serial: legacy ? '$address:5555' : tlsSerial, + status: 'device', + transport: 'wifi', + ip: legacy ? address : null, + port: legacy ? '5555' : null, + name: 'Peloton Interactive LLC PLTN-RB1VO-2', + hardwareSerial: hardwareId, + manufacturer: 'Peloton Interactive LLC', +); + +final kindle = DeviceModel( + serial: 'KINDLE123', + status: 'device', + transport: 'usb', + name: 'Amazon KFRAPWI', + manufacturer: 'Amazon', + hardwareSerial: 'KINDLE123', +); + +class FakeAdb extends AdbService { + final List calls = []; + String expectedIdentity = identityKey; + bool listenerEnabled = false; + bool reachable = true; + bool wrongEndpoint = false; + bool wrongSource = false; + bool identityTimeout = false; + String address = ip; + Completer? connectionGate; + + FakeAdb() : super(onLog: (_, _) {}); + + @override + bool get isMobile => false; + + @override + Future isExpectedDevice(String serial, String id) async { + calls.add('verify $serial $id'); + if (serial.endsWith(':5555') && identityTimeout) { + throw TimeoutException('identity'); + } + return id == expectedIdentity && + !(serial.endsWith(':5555') ? wrongEndpoint : wrongSource); + } + + @override + Future getDeviceIp(String serial) async { + calls.add('ip $serial'); + return address; + } + + @override + Future connectLegacyWifi(String address) async { + calls.add('connect $address:5555'); + if (connectionGate != null) await connectionGate!.future; + if (!listenerEnabled || !reachable) throw StateError('Connection refused'); + } + + @override + Future connectTcpIp(String serial) async { + calls.add('tcpip $serial'); + listenerEnabled = true; + } + + @override + Future disconnectEndpoint(String serial) async { + calls.add('disconnect $serial'); + } +} + +void main() { + late FakeAdb adb; + late WirelessConnectionManager manager; + late List saved; + late List busy; + late DateTime clock; + + setUp(() { + adb = FakeAdb(); + saved = []; + busy = []; + clock = DateTime(2026, 9, 15); + manager = WirelessConnectionManager( + adb: adb, + save: (data) async { + saved.add(data); + }, + onBusy: busy.add, + now: () => clock, + delay: (_) async {}, + ); + }); + + test( + 'sets up verified Peloton, resolves IP before restart, leaves Kindle alone', + () async { + expect(await manager.maintain([kindle, peloton()]), isTrue); + expect(adb.calls, [ + 'verify $tlsSerial $identityKey', + 'ip $tlsSerial', + 'connect $endpoint', + 'verify $tlsSerial $identityKey', + 'tcpip $tlsSerial', + 'connect $endpoint', + 'verify $endpoint $identityKey', + ]); + expect(jsonDecode(saved.single), {identityKey: ip}); + expect(busy, [true, false]); + }, + ); + + test('reconnects after app restart with no TLS transport', () async { + manager.restore(jsonEncode({identityKey: ip})); + adb.listenerEnabled = true; + expect(await manager.maintain([kindle]), isTrue); + expect(adb.calls, ['connect $endpoint', 'verify $endpoint $identityKey']); + expect(saved, isEmpty); + }); + + test( + 'learns an already connected legacy endpoint without restarting ADB', + () async { + expect(await manager.maintain([peloton(legacy: true), kindle]), isTrue); + adb.calls.clear(); + expect( + await manager.maintain([peloton(), peloton(legacy: true), kindle]), + isFalse, + ); + expect(adb.calls, isEmpty); + }, + ); + + test( + 'does not configure USB-only or unidentified wireless devices', + () async { + expect( + await manager.maintain([ + kindle, + DeviceModel( + serial: 'other:5555', + status: 'device', + transport: 'wifi', + name: 'Amazon Kindle', + ), + DeviceModel( + serial: tlsSerial, + status: 'device', + transport: 'wifi', + name: 'Peloton tablet', + ), + ]), + isFalse, + ); + expect(adb.calls, isEmpty); + }, + ); + + test( + 'automatically sets up and remembers a non-Peloton wireless device', + () async { + final tablet = DeviceModel( + serial: 'adb-TABLET123._adb-tls-connect._tcp', + status: 'device', + transport: 'wifi', + name: 'Samsung tablet', + manufacturer: 'Samsung', + hardwareSerial: 'TABLET123', + ); + adb.expectedIdentity = tablet.identityKey!; + expect(await manager.maintain([kindle, tablet]), isTrue); + expect(adb.calls, contains('tcpip ${tablet.serial}')); + expect(jsonDecode(saved.single), {tablet.identityKey: ip}); + final restored = WirelessConnectionManager( + adb: adb, + save: (_) async {}, + onBusy: (_) {}, + )..restore(saved.single); + adb.calls.clear(); + expect(await restored.maintain([kindle]), isTrue); + expect(adb.calls, [ + 'connect $endpoint', + 'verify $endpoint ${tablet.identityKey}', + ]); + }, + ); + + test( + 'rejects source identity mismatch before saving or enabling TCP/IP', + () async { + adb.wrongSource = true; + expect(await manager.maintain([peloton()]), isFalse); + expect(adb.calls, ['verify $tlsSerial $identityKey']); + expect(saved, isEmpty); + }, + ); + + test( + 'disconnects a reassigned IP and never configures its new occupant', + () async { + manager.restore(jsonEncode({identityKey: ip})); + adb.listenerEnabled = true; + adb.wrongEndpoint = true; + expect(await manager.maintain([]), isFalse); + expect(adb.calls, [ + 'connect $endpoint', + 'verify $endpoint $identityKey', + 'disconnect $endpoint', + ]); + }, + ); + + test( + 'disconnects a newly connected endpoint if identity verification times out', + () async { + manager.restore(jsonEncode({identityKey: ip})); + adb.listenerEnabled = true; + adb.identityTimeout = true; + expect(await manager.maintain([]), isFalse); + expect(adb.calls.last, 'disconnect $endpoint'); + }, + ); + + test( + 'leaves an existing unrelated connection at the old IP untouched', + () async { + manager.restore(jsonEncode({identityKey: ip})); + expect( + await manager.maintain([ + DeviceModel( + serial: endpoint, + status: 'device', + transport: 'wifi', + name: 'Amazon Kindle', + ), + ]), + isFalse, + ); + expect(adb.calls, isEmpty); + }, + ); + + test('updates remembered IP from a verified TLS connection', () async { + manager.restore(jsonEncode({identityKey: '192.168.1.10'})); + adb.listenerEnabled = true; + expect(await manager.maintain([peloton()]), isTrue); + expect(jsonDecode(saved.single), {identityKey: ip}); + expect(adb.calls, isNot(contains('connect 192.168.1.10:5555'))); + }); + + test('backs off failures and resets after recovery', () async { + manager.restore(jsonEncode({identityKey: ip})); + expect(await manager.maintain([]), isFalse); + clock = clock.add(const Duration(seconds: 5)); + await manager.maintain([]); + expect(adb.calls.length, 1); + clock = clock.add(const Duration(seconds: 5)); + await manager.maintain([]); + expect(adb.calls.length, 2); + clock = clock.add(const Duration(seconds: 19)); + await manager.maintain([]); + expect(adb.calls.length, 2); + clock = clock.add(const Duration(seconds: 1)); + adb.listenerEnabled = true; + expect(await manager.maintain([]), isTrue); + expect(await manager.maintain([]), isTrue); + }); + + test('does not overlap attempts or restart ADB after disposal', () async { + adb.connectionGate = Completer(); + final pending = manager.maintain([peloton()]); + // Allow identity, IP lookup and preference saving to complete. + await Future.delayed(Duration.zero); + expect(await manager.maintain([peloton()]), isFalse); + manager.dispose(); + adb.connectionGate!.complete(); + expect(await pending, isFalse); + expect(adb.calls.where((c) => c.startsWith('tcpip')), isEmpty); + expect(busy, [true, false]); + }); + + test('ignores damaged preferences and stops after disposal', () async { + manager.restore('{broken'); + manager.restore(jsonEncode({'bad': 'not-an-ip', '': ip})); + expect(await manager.maintain([]), isFalse); + manager.dispose(); + expect(await manager.maintain([peloton()]), isFalse); + expect(adb.calls, isEmpty); + }); + + test( + 'prefers legacy Peloton while preserving Kindle and USB connections', + () { + final usb = DeviceModel( + serial: hardwareId, + status: 'device', + transport: 'usb', + name: 'Peloton tablet', + hardwareSerial: hardwareId, + manufacturer: 'Peloton Interactive LLC', + ); + final legacy = peloton(legacy: true); + expect(prioritizeDeviceConnections([kindle, peloton(), legacy, usb]), [ + legacy, + usb, + kindle, + ]); + }, + ); + + test('sorts Pelotons first and deduplicates other manufacturers too', () { + final otherTls = DeviceModel( + serial: 'adb-other._adb-tls-connect._tcp', + status: 'device', + transport: 'wifi', + manufacturer: 'Samsung', + hardwareSerial: 'other', + ); + final otherLegacy = DeviceModel( + serial: '192.168.1.20:5555', + status: 'device', + transport: 'wifi', + port: '5555', + manufacturer: 'Samsung', + hardwareSerial: 'other', + ); + final bike = peloton(); + expect(prioritizeDeviceConnections([kindle, otherTls, bike, otherLegacy]), [ + bike, + kindle, + otherLegacy, + ]); + }); +}