fix: android: replace all-files access with scoped storage (#15602)

* fix: android: replace all-files access with scoped storage + system picker

Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and
WRITE_EXTERNAL_STORAGE from the Android manifest. Remove
requestLegacyExternalStorage. Replace broad external storage with
app-scoped external storage for the file-transfer workspace.

File import uses the system file_picker. File export uses Android's
SAF ACTION_CREATE_DOCUMENT with path validation that restricts
export sources to app-owned directories.

Remove the external_path dependency.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: refine file import feedback

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: use SAF for file imports

Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: keep file imports in the selected directory

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: reset projection and constrain file workspace

Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android, enforce file workspace boundary on the server, and unblock the ui thread.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones.

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>

* fix(android): harden scoped storage file handling

- create new SAF documents instead of overwriting export sources
- reject empty peer paths except for home directory reads
- report directory backup restore and cleanup failures
- resolve log export paths from the configured app name

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): harden scoped-storage file operations

- snapshot directory exports before writing to the destination
- query document provider metadata off the main thread
- reject invalid remote directories without read timeouts

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): handle SAF directory name collisions

- reject dot-segment folder names during import
- fail imports with duplicate document display names
- only reuse matching directories during export

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(android): handle SAF folder import collisions

Reject filesystem-equivalent destination names and
avoid showing a failure when folder overwrite is skipped.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
Michael Clark
2026-08-31 18:29:51 +10:00
committed by GitHub
parent 03a7fc5992
commit d4b06a6c5c
70 changed files with 1304 additions and 71 deletions

View File

@@ -1519,13 +1519,6 @@ class AndroidPermissionManager {
static Timer? _timer;
static var _current = "";
static bool isWaitingFile() {
if (_completer != null) {
return !_completer!.isCompleted && _current == kManageExternalStorage;
}
return false;
}
static Future<bool> check(String type) {
if (isDesktop || isWeb) {
return Future.value(true);
@@ -2634,13 +2627,6 @@ connect(BuildContext context, String id,
}
} else {
if (isFileTransfer) {
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
}
}
}
if (isWeb) {
Navigator.push(
context,

View File

@@ -439,7 +439,6 @@ const kActionApplicationDetailsSettings =
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
const kRecordAudio = "android.permission.RECORD_AUDIO";
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
const kRequestIgnoreBatteryOptimizations =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
@@ -451,6 +450,12 @@ class AndroidChannel {
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
static final kSyncAppDirConfigPath = "sync_app_dir";
static final kPickImportFiles = "pick_import_files";
static final kImportFile = "import_file";
static final kExportFile = "export_file";
static final kPickImportDirectory = "pick_import_directory";
static final kImportDirectory = "import_directory";
static final kExportFiles = "export_files";
}
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../consts.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
gFFI.ffiModel.beginAndroidDocumentPicker();
try {
return await action();
} finally {
gFFI.ffiModel.endAndroidDocumentPicker();
}
}
Future<void> _importFiles() async {
var imported = 0;
var failed = false;
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final selectedFiles = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<List<dynamic>>(
AndroidChannel.kPickImportFiles));
if (selectedFiles == null || selectedFiles.isEmpty) return;
for (final selected in selectedFiles) {
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
final selectedName = selected['name'] as String?;
final name = selectedName?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
!PathUtil.validName(name, importIsWindows)) {
failed = true;
continue;
}
final destination =
PathUtil.join(importDirectory, name, importIsWindows);
var overwrite = false;
if (await File(destination).exists()) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult == false) break;
if (overwriteResult != true) continue;
overwrite = true;
}
try {
final success = await gFFI.invokeMethod(
AndroidChannel.kImportFile,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
imported++;
} else {
failed = true;
}
} catch (e) {
failed = true;
debugPrint('Failed to import $name: $e');
}
}
} catch (e) {
failed = true;
debugPrint('Failed to select files for import: $e');
}
await importController.refresh();
if (failed) {
showToast(translate('Failed'));
} else if (imported > 0) {
showToast(translate('Successful'));
}
}
Future<void> _exportFile(Entry entry) async {
try {
final exported = await _runAndroidDocumentPicker(() => gFFI
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
if (exported == true) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export ${entry.name}: $e');
showToast(translate('Failed'));
}
}
Future<void> _importFolder() async {
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final picked = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kPickImportDirectory));
if (picked == null || picked.isEmpty) return;
final uri = picked['uri'] as String?;
final name =
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
name == '.' ||
name == '..' ||
!PathUtil.validName(name, importIsWindows)) {
showToast(translate('Failed'));
return;
}
final destination = PathUtil.join(importDirectory, name, importIsWindows);
final destinationType = await FileSystemEntity.type(destination);
var overwrite = false;
if (destinationType == FileSystemEntityType.directory) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult != true) return;
overwrite = true;
} else if (destinationType != FileSystemEntityType.notFound) {
showToast(translate('Failed'));
return;
}
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
showToast(translate('Successful'));
} else {
showToast(translate('Failed'));
}
} catch (e) {
debugPrint('Failed to import folder: $e');
showToast(translate('Failed'));
}
await importController.refresh();
}
Future<void> _exportItems(SelectedItems items) async {
await _exportPaths(items.items.map((e) => e.path));
}
Future<void> _exportLogs() async {
final home = currentFileController.homePath;
if (home.isEmpty) {
showToast(translate('Failed'));
return;
}
final appDir = PathUtil.join(home, appName, false);
final paths = [
PathUtil.join(appDir, 'Logs', false),
PathUtil.join(appDir, 'ScreenRecord', false),
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
if (paths.isEmpty) {
showToast(translate('Failed'));
return;
}
await _exportPaths(paths);
}
Future<void> _exportPaths(Iterable<String> paths) async {
try {
final result = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
if (result == null) return;
final exported = result['exported'] as int? ?? 0;
final failed = result['failed'] as int? ?? 0;
if (failed > 0) {
showToast(translate('Failed'));
} else if (exported > 0) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export paths: $e');
showToast(translate('Failed'));
}
}
@override
void initState() {
super.initState();
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
),
value: "refresh",
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import",
child: Row(
children: [
Icon(Icons.add_to_drive,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Add"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import_folder",
child: Row(
children: [
Icon(Icons.create_new_folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Import Folder"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "export_logs",
child: Row(
children: [
Icon(Icons.article_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Export Logs"))
],
),
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
onSelected: (v) {
if (v == "refresh") {
currentFileController.refresh();
} else if (v == "import") {
_importFiles();
} else if (v == "import_folder") {
_importFolder();
} else if (v == "export_logs") {
_exportLogs();
} else if (v == "select") {
model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear();
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {});
},
actions: [
if (isAndroid &&
selectedItems?.isLocal == true &&
selectedItems?.items.isNotEmpty == true) ...[
if (selectedItems!.items.length == 1 &&
selectedItems!.items.single.isFile)
IconButton(
tooltip: translate("Save as"),
icon: Icon(Icons.save_alt),
onPressed: () =>
_exportFile(selectedItems!.items.single),
)
else
IconButton(
tooltip: translate("Export"),
icon: Icon(Icons.drive_folder_upload),
onPressed: () => _exportItems(selectedItems!),
),
],
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),

View File

@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
void checkService() async {
gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
}
class ServiceNotRunningNotification extends StatelessWidget {

View File

@@ -381,6 +381,14 @@ class FileController {
void set homePath(String path) => options.value.home = path;
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
bool _isPathAllowed(String candidate) {
if (!isAndroid || !isLocal) return true;
if (homePath.isEmpty || candidate.isEmpty) return false;
final home = PathUtil.posixContext.normalize(homePath);
final target = PathUtil.posixContext.normalize(candidate);
return target == home || PathUtil.posixContext.isWithin(home, target);
}
String get shortPath {
final dirPath = directory.value.path;
if (dirPath.startsWith(homePath)) {
@@ -414,8 +422,13 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100));
final savedDir = (await bind.sessionGetPeerOption(
var savedDir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
savedDir = options.value.home;
await bind.sessionPeerOption(
sessionId: sessionId, name: "local_dir", value: savedDir);
}
Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path,
@@ -485,6 +498,9 @@ class FileController {
}
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!_isPathAllowed(path)) {
return false;
}
if (!isBack) {
pushHistory();
}
@@ -504,6 +520,7 @@ class FileController {
return true;
}
fd.format(isWindows, sort: sortBy.value);
selectedItems.reconcile(fd.entries);
directory.value = fd;
return true;
} catch (e) {
@@ -550,6 +567,9 @@ class FileController {
final isWindows = options.value.isWindows;
final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows);
if (!_isPathAllowed(parent)) {
return true;
}
// specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) {
return await _openDirectoryPath('/', isBack: isBack);
@@ -1885,7 +1905,7 @@ class PathUtil {
}
static bool validName(String name, bool isWindows) {
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
return reg.hasMatch(name);
@@ -1928,6 +1948,21 @@ class SelectedItems {
items.clear();
}
void reconcile(List<Entry> entries) {
if (items.isEmpty) return;
final currentByPath = {for (final entry in entries) entry.path: entry};
final reconciled = <Entry>[];
for (final item in items) {
final current = currentByPath[item.path];
if (current != null && current.entryType == item.entryType) {
reconciled.add(current);
}
}
items
..clear()
..addAll(reconciled);
}
void selectAll(List<Entry> entries) {
items.clear();
items.addAll(entries);

View File

@@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier {
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _androidDocumentPickerActive = false;
bool _androidDocumentPickerInterruptedConnection = false;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference<FFI> parent;
@@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier {
_inputBlocked = false;
_timer?.cancel();
_timer = null;
_androidDocumentPickerActive = false;
_androidDocumentPickerInterruptedConnection = false;
resetRestartReconnectState();
clearPermissions();
waitForImageTimer?.cancel();
@@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier {
final text = evt['text'];
final link = evt['link'];
if (isAndroid &&
_androidDocumentPickerActive &&
title == 'Connection Error') {
_androidDocumentPickerInterruptedConnection = true;
return;
}
// Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck.
@@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier {
_restartReconnectDelayTimer = null;
}
void beginAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = true;
_androidDocumentPickerInterruptedConnection = false;
}
void endAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = false;
if (!_androidDocumentPickerInterruptedConnection ||
parent.target?.closed == true) {
return;
}
_androidDocumentPickerInterruptedConnection = false;
reconnect(parent.target!.dialogManager, sessionId, false);
}
/// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline(
@@ -4060,6 +4088,11 @@ class FFI {
return await platformFFI.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
}
// Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:external_path/external_path.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -171,8 +170,10 @@ class PlatformFFI {
_startListenEvent(_ffiBind); // global event
try {
if (isAndroid) {
// only support for android
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
// Android file transfer uses app-specific storage. User-selected
// files enter and leave this workspace through the system picker.
_homeDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationSupportDirectory()).path;
} else if (isIOS) {
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
// which provided the `downloads` path in the sandbox.
@@ -306,6 +307,12 @@ class PlatformFFI {
return await _toAndroidChannel.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
if (!isAndroid) return null;
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
}
void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
}

View File

@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
_audioOk = audioOption != 'N';
}
// file
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
_fileOk = false;
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
} else {
final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
}
// Android file transfer is confined to app-specific storage. Files enter
// and leave the workspace through Android's system document picker.
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
// clipboard
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
!await AndroidPermissionManager.check(kManageExternalStorage)) {
final res =
await AndroidPermissionManager.request(kManageExternalStorage);
if (!res) {
showToast(translate('Failed'));
return;
}
}
_fileOk = !_fileOk;
bind.mainSetOption(
key: kOptionEnableFileTransfer,
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
await checkFloatingWindowPermission();
}
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
await AndroidPermissionManager.request(kManageExternalStorage);
}
final res = await parent.target?.dialogManager
.show<bool>((setState, close, context) {
submit() => close(true);

View File

@@ -251,6 +251,11 @@ class PlatformFFI {
return true;
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return null;
}
// just for compilation
void syncAndroidServiceAppDirConfigPath() {}