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 diff --git a/README.md b/README.md index ab83e8d..3751401 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 @@ -48,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_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/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 353fbd5..8ca2f2f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -9,11 +9,15 @@ 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/wireless_connection_manager.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'; @@ -49,27 +53,43 @@ 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; bool isRecording = false; String? _saveLocation; + bool _askEachDownload = false; bool isCheckingForUpdate = false; String? currentAppVersion; String? latestAppVersion; 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(); }); @@ -78,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) { @@ -90,7 +117,7 @@ class AppProvider with ChangeNotifier { } void _setBusy(bool value) { - isBusy = value; + _operationBusy = value; notifyListeners(); } @@ -212,18 +239,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) { @@ -234,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; @@ -249,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( @@ -262,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(); } @@ -277,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(); @@ -846,12 +940,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 { @@ -1291,16 +1400,150 @@ class AppProvider with ChangeNotifier { return {'success': success, 'fail': fail}; } - 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!]); + // --------------------------------------------------------------------------- + // File manager + // --------------------------------------------------------------------------- + + 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 { + 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(deviceSerial, 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 [destination], or the save location when omitted. + /// Returns the local path on success. + Future downloadDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, { + String? destination, + }) async { + final root = destination ?? saveLocation; + if (root.isEmpty) return null; + _setBusy(true); + try { + final localPath = _uniqueLocalPath(safeLocalEntryPath(root, entry.name)); + await _adbService.pullEntry(deviceSerial, 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 deviceSerial, + String parentDir, + String name, + ) async { + final path = joinRemotePath(parentDir, name); + final ok = await _adbService.makeDirectory(deviceSerial, path); + _onLog( + ok ? "Created folder $path" : "Failed to create folder $path", + ok ? 'info' : 'error', + ); + return ok; + } + + Future renameDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, + String newName, + ) async { + final target = joinRemotePath(parentRemotePath(entry.path), newName); + final ok = await _adbService.renameEntry(deviceSerial, entry.path, target); + _onLog( + ok + ? "Renamed ${entry.name} to $newName" + : "Failed to rename ${entry.name}", + ok ? 'info' : 'error', + ); + return ok; + } + + Future deleteDeviceEntry( + String deviceSerial, + DeviceFileEntry entry, + ) async { + _setBusy(true); + try { + final ok = await _adbService.deleteEntry(deviceSerial, entry.path); + _onLog( + ok ? "Deleted ${entry.path}" : "Failed to delete ${entry.path}", + ok ? 'info' : 'error', + ); + return ok; + } finally { + _setBusy(false); } } + /// 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 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 7595e76..3c83b43 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,19 @@ class HomeScreen extends StatelessWidget { builder: (_) => const InstalledAppManagerDialog(), ); } + } else if (value == 'file_manager') { + final deviceSerial = provider.selectedDevice?.serial; + if (deviceSerial == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("No device selected")), + ); + } else { + showDialog( + context: context, + builder: (_) => + FileManagerDialog(deviceSerial: deviceSerial), + ); + } } else if (value == 'dev_options') { if (provider.selectedDevice == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -120,6 +134,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'), @@ -323,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", @@ -418,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( @@ -435,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/services/adb_service.dart b/lib/services/adb_service.dart index 5e25895..26d47b2 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'; @@ -22,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; @@ -127,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 @@ -143,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(); @@ -162,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), '']; @@ -216,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; @@ -238,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( @@ -248,6 +275,8 @@ class AdbService { port: port, name: name, abi: abi, + hardwareSerial: identity?.$2, + manufacturer: identity?.$1, ), ); } @@ -491,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. @@ -1238,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); @@ -1253,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); @@ -1612,6 +1699,213 @@ 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."); + } + 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( + String serial, + String remotePath, + String localPath, + ) async { + 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; + } + } + + Future makeDirectory(String serial, String path) => + _runSilentShellCommand(serial, 'mkdir -p ${quoteShellArg(path)}'); + + 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, + '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)}'); + + /// 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; + } + } + + 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 } + +/// 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) { diff --git a/lib/services/device_file_parser.dart b/lib/services/device_file_parser.dart new file mode 100644 index 0000000..4895202 --- /dev/null +++ b/lib/services/device_file_parser.dart @@ -0,0 +1,128 @@ +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); +} + +/// 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(.*)$', +); + +/// 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', + ), + ); + } + + 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; + + 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; + }); +} 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/lib/widgets/file_manager_dialog.dart b/lib/widgets/file_manager_dialog.dart new file mode 100644 index 0000000..7ee7d3d --- /dev/null +++ b/lib/widgets/file_manager_dialog.dart @@ -0,0 +1,828 @@ +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'; + +/// 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 = 192; // three IconButtons + overflow menu +const double _arrowSlotWidth = 18; + +class FileManagerDialog extends StatefulWidget { + final String deviceSerial; + + const FileManagerDialog({super.key, required this.deviceSerial}); + + @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 = []; + DeviceFileSort _sort = DeviceFileSort.name; + bool _ascending = true; + bool _loading = true; + bool _transferring = false; + String? _transferLabel; + double? _transferProgress; + 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() { + 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 _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( + widget.deviceSerial, + target, + ); + if (!mounted) return; + sortDeviceFiles(entries, sort: _sort, ascending: _ascending); + 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); + + /// 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}) { + if (!mounted) return; + setState(() { + _statusMessage = message; + _statusIsError = isError; + }); + } + + Future _upload() async { + setState(() { + _transferring = true; + _transferLabel = "Waiting for file selection..."; + _transferProgress = null; + _lastDownloadPath = null; + _lastDownloadDir = null; + }); + final count = await _provider.uploadFilesToDevice( + widget.deviceSerial, + _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(); + } + + /// 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", + 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( + widget.deviceSerial, + _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( + widget.deviceSerial, + 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(widget.deviceSerial, entry); + _setStatus( + ok ? "Deleted ${entry.name}" : "Could not delete ${entry.name}", + isError: !ok, + ); + if (ok) await _refresh(); + } + + @override + Widget build(BuildContext context) { + context.select( + (provider) => provider.selectedDevice?.serial, + ); + 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(), + ), + ], + ), + ), + _buildSortHeader(), + Expanded(child: _buildFileList()), + _buildDestinationBar(), + ], + ), + ); + } + + 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()); + } + 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), + onDownloadTo: () => _download(entry, chooseDestination: true), + onRename: () => _rename(entry), + onDelete: () => _confirmDelete(entry), + ); + }, + ); + } + + /// 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; + 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, + 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 containing folder", + onPressed: () => + _provider.openSaveLocation(path: _lastDownloadDir), + icon: const Icon(Icons.folder, size: 18), + visualDensity: VisualDensity.compact, + ), + if (_statusMessage != null && !_transferring) + IconButton( + tooltip: "Clear status", + onPressed: () => setState(() { + _statusMessage = null; + _lastDownloadPath = null; + _lastDownloadDir = 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 onDownloadTo; + final VoidCallback onRename; + final VoidCallback onDelete; + + const _DeviceFileTile({ + required this.entry, + 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; + final detailStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant); + + 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, + onSecondaryTapUp: enabled + ? (details) => _showContextMenu(context, details.globalPosition) + : null, + 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", + 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), + ), + 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 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/device_file_parser_test.dart b/test/device_file_parser_test.dart new file mode 100644 index 0000000..9697d03 --- /dev/null +++ b/test/device_file_parser_test.dart @@ -0,0 +1,245 @@ +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', () { + 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('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'"); + 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'); + }); + + 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', () { + 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'); + }); + }); +} 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, + ]); + }); +}