external_storage 0.0.1+3 copy "external_storage: ^0.0.1+3" to clipboard
external_storage: ^0.0.1+3 copied to clipboard

PlatformAndroid

A powerful Flutter Android external storage management plugin that provides complete file system operations, storage device management, file monitoring, and permission handling functionality.

example/lib/main.dart

import 'dart:io' show File, Platform;
import 'package:flutter/foundation.dart' show Uint8List, kDebugMode;
import 'package:flutter/material.dart';
import 'package:external_storage/external_storage.dart'
    show ExternalStorage, FileInfo, StorageDevice, WatchEventType;
import 'package:open_file/open_file.dart' show OpenFile, ResultType;
import 'package:path/path.dart' as path;
import 'package:mime/mime.dart' show lookupMimeType;
import 'package:permission_handler/permission_handler.dart'
    show
        Permission,
        PermissionActions,
        PermissionCheckShortcuts,
        PermissionStatusGetters,
        openAppSettings;
import 'package:device_info_plus/device_info_plus.dart' show DeviceInfoPlugin;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'External Storage Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: const StorageDemo(),
    );
  }
}

class StorageDemo extends StatefulWidget {
  final ExternalStorage? storage;

  const StorageDemo({super.key, this.storage});

  @override
  StorageDemoState createState() => StorageDemoState();
}

class StorageDemoState extends State<StorageDemo> {
  late final ExternalStorage storage;
  final List<String> _logs = [];
  String _currentPath = '';
  List<FileInfo> _currentEntries = [];
  List<StorageDevice> _devices = [];
  bool _isLoading = false;
  bool _hasPermission = false;
  String _permissionStatus = '未检查';

  // 复制
  FileInfo? _copyItem;
  bool _isCopyOperationActive = false;

  // 剪切
  FileInfo? _cutItem;
  bool _isCutOperationActive = false;

  @override
  void initState() {
    super.initState();
    storage = widget.storage ?? ExternalStorage();
    _initializeStorage();
    // 注册文件系统监听
    storage.registerWatchEventCallback(_onFileSystemEvent);
  }

  Future<void> _initializeStorage() async {
    setState(() {
      _isLoading = true;
      _permissionStatus = '检查权限中...';
    });

    try {
      _addLog('开始检查权限...');
      _hasPermission = await _checkAndRequestPermissions();
      _addLog('权限检查结果: $_hasPermission');

      if (!_hasPermission) {
        _permissionStatus = '权限被拒绝';
        _addLog('未获得存储权限');
        setState(() => _isLoading = false);
        return;
      }

      _permissionStatus = '已获得权限';
      setState(() {});

      // 获取存储设备
      _addLog('获取存储设备列表...');
      _devices = await storage.getAllStorageDevices();
      _addLog('找到 ${_devices.length} 个存储设备');
    } catch (e, s) {
      _permissionStatus = '初始化错误';
      _addLog('初始化错误: $e\n$s');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<bool> _checkAndRequestPermissions() async {
    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;

      if (androidInfo.version.sdkInt >= 33) {
        // Android 13+ (API 33+)
        final images = await Permission.photos.request();
        final videos = await Permission.videos.request();
        final audio = await Permission.audio.request();
        final manageStorage = await Permission.manageExternalStorage.request();

        return images.isGranted &&
            videos.isGranted &&
            audio.isGranted &&
            manageStorage.isGranted;
      } else if (androidInfo.version.sdkInt >= 30) {
        // Android 11+ (API 30+)
        if (!await Permission.manageExternalStorage.isGranted) {
          final status = await Permission.manageExternalStorage.request();
          if (!status.isGranted) {
            await openAppSettings();
            return false;
          }
        }
        return true;
      } else {
        // Android 10 及以下版本
        final storage = await Permission.storage.request();
        return storage.isGranted;
      }
    }
    return true;
  }

  Future<void> _loadCurrentDirectory() async {
    setState(() => _isLoading = true);
    try {
      _addLog('开始加载目录: $_currentPath');
      _currentEntries = await storage.listDirectory(_currentPath);
      _addLog('成功加载目录,共 ${_currentEntries.length} 个项目');

      // 开始监听当前目录
      if (_currentPath.isNotEmpty) {
        await storage.startWatching(_currentPath);
        _addLog('开始监听目录: $_currentPath');
      }

      setState(() {});
    } catch (e, s) {
      _addLog('加载目录错误: $e\n堆栈: $s');
      setState(() {
        _currentEntries = [];
      });
    } finally {
      setState(() => _isLoading = false);
    }
  }

  void _onFileSystemEvent(String path, WatchEventType event) {
    _addLog('文件事件: ${event.name} - $path');
    if (path.startsWith(_currentPath)) {
      _loadCurrentDirectory();
    }
  }

  void _addLog(String message) {
    // 在终端打印日志
    if (kDebugMode) {
      print('StorageDemo: $message');
    }

    setState(() {
      _logs.insert(0, '${DateTime.now().toString()}: $message');
      if (_logs.length > 100) _logs.removeLast();
    });
  }

  Future<void> _cutItemAction(FileInfo item) async {
    setState(() {
      _cutItem = item;
      _isCutOperationActive = true;
    });
    _addLog('剪切: ${item.path}');
  }

  Future<void> _pasteItem() async {
    if (_cutItem == null) return;

    try {
      final newPath = path.join(_currentPath, _cutItem!.name);
      if (_cutItem!.isFile) {
        await storage.moveFile(_cutItem!.path, newPath);
      } else {
        await storage.moveDirectory(_cutItem!.path, newPath);
      }
      _addLog('粘贴: ${_cutItem!.path} 到 $newPath');
      setState(() {
        _cutItem = null;
        _isCutOperationActive = false;
      });
      await _loadCurrentDirectory();
    } catch (e) {
      _addLog('粘贴错误: $e');
    }
  }

  void _cancelCut() {
    setState(() {
      _cutItem = null;
      _isCutOperationActive = false;
    });
    _addLog('取消剪切');
  }

  Future<void> _createTestFile() async {
    try {
      const baseName = 'test';
      const extension = '.txt';
      String testPath = path.join(_currentPath, '$baseName$extension');
      int counter = 1;

      // 检查文件是否存在,如果存在则添加后缀
      while (await storage.fileExists(testPath)) {
        testPath = path.join(_currentPath, '${baseName}_$counter$extension');
        counter++;
      }

      final data = Uint8List.fromList('Hello, World!'.codeUnits);
      final bytesWritten = await storage.writeFile(testPath, data);
      _addLog('创建测试文件: $bytesWritten bytes at $testPath');
      await _loadCurrentDirectory();
    } catch (e) {
      _addLog('创建文件错误: $e');
    }
  }

  Future<void> _createTestDirectory() async {
    try {
      const baseName = 'test_dir';
      String dirPath = path.join(_currentPath, baseName);
      int counter = 1;

      // 检查目录是否存在,如果存在则添加后缀
      while (await storage.directoryExists(dirPath)) {
        dirPath = path.join(_currentPath, '${baseName}_$counter');
        counter++;
      }

      final created = await storage.createDirectory(dirPath);
      _addLog('创建测试目录: ${created ? '成功' : '失败'} at $dirPath');
      await _loadCurrentDirectory();
    } catch (e) {
      _addLog('创建目录错误: $e');
    }
  }

  Future<void> _showItemInfo(FileInfo item) async {
    try {
      if (item.isFile) {
        _addLog('获取文件信息: ${item.path}');
        final info = await storage.getFileInfo(item.path);
        final md5 = await storage.calculateMD5(item.path);
        _showInfoDialog(
          '文件信息',
          '名称: ${info.name}\n'
              '大小: ${_formatSize(info.size)}\n'
              '修改时间: ${info.lastModified}\n'
              'MD5: $md5',
        );
      } else {
        _addLog('获取目录信息: ${item.path}');
        final info = await storage.getDirectoryInfo(item.path);
        _showInfoDialog(
          '目录信息',
          '名称: ${info.name}\n'
              '文件数量: ${info.fileCount}\n'
              '目录数量: ${info.directoryCount}\n'
              '总空间: ${_formatSize(info.totalSpace)}\n'
              '可用空间: ${_formatSize(info.freeSpace)}',
        );
      }
    } catch (e, s) {
      _addLog('获取信息错误: $e\n堆栈: $s');
    }
  }

  String _formatSize(int bytes) {
    const suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
    var i = 0;
    double size = bytes.toDouble();
    while (size >= 1024 && i < suffixes.length - 1) {
      size /= 1024;
      i++;
    }
    return '${size.toStringAsFixed(2)} ${suffixes[i]}';
  }

  Future<void> _showInfoDialog(String title, String content) {
    return showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(content),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }

  Widget _buildDeviceList() {
    if (_devices.isEmpty) {
      return const Center(
        child: Text('未找到存储设备'),
      );
    }

    return ListView.builder(
      itemCount: _devices.length,
      itemBuilder: (context, index) {
        final device = _devices[index];
        return ListTile(
          leading: Icon(
            device.isRemovable ? Icons.sd_card : Icons.phone_android,
            color: device.isRemovable ? Colors.green : Colors.blue,
          ),
          title: Text(device.name),
          subtitle: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(device.path),
              Text(
                  '可用: ${_formatSize(device.availableSize)} / 总共: ${_formatSize(device.totalSize)}'),
            ],
          ),
          isThreeLine: true,
          onTap: () async {
            try {
              setState(() {
                _currentPath = device.path;
                _isLoading = true;
              });
              await _loadCurrentDirectory();
            } catch (e, s) {
              _addLog('切换目录错误: $e\n堆栈: $s');
            }
          },
        );
      },
    );
  }

  Widget _buildListItem(FileInfo item) {
    return ListTile(
      leading: Icon(
        item.isDirectory ? Icons.folder : Icons.insert_drive_file,
        color: item.isDirectory ? Colors.amber : Colors.blue,
      ),
      title: Text(
        item.name,
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
      subtitle: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            item.path,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
          ),
          Text(
            item.isFile
                ? _formatSize(item.size)
                : '目录 - ${item.lastModified.toString().split('.')[0]}',
          ),
        ],
      ),
      isThreeLine: true,
      trailing: PopupMenuButton<String>(
        onSelected: (value) async {
          try {
            switch (value) {
              case 'info':
                await _showItemInfo(item);
                break;
              case 'delete':
                final confirmed = await showDialog<bool>(
                      context: context,
                      builder: (context) => AlertDialog(
                        title: const Text('确认删除'),
                        content: Text(
                            '确定要删除${item.isFile ? '文件' : '目录'} "${item.name}" 吗?'),
                        actions: [
                          TextButton(
                            onPressed: () => Navigator.pop(context, false),
                            child: const Text('取消'),
                          ),
                          TextButton(
                            onPressed: () => Navigator.pop(context, true),
                            child: const Text('确定'),
                          ),
                        ],
                      ),
                    ) ??
                    false;

                if (confirmed) {
                  if (item.isFile) {
                    await storage.deleteFile(item.path);
                  } else {
                    await storage.deleteDirectory(item.path, recursive: true);
                  }
                  _addLog('删除${item.isFile ? '文件' : '目录'}: ${item.name}');
                  await _loadCurrentDirectory();
                }
                break;
            }
          } catch (e, s) {
            _addLog('操作错误: $e\n堆栈: $s');
          }
        },
        itemBuilder: (context) => [
          const PopupMenuItem(
            value: 'info',
            child: Text('信息'),
          ),
          const PopupMenuItem(
            value: 'delete',
            child: Text('删除'),
          ),
        ],
      ),
      onTap: item.isDirectory
          ? () async {
              try {
                setState(() {
                  _currentPath = item.path;
                  _isLoading = true;
                });
                await _loadCurrentDirectory();
              } catch (e, s) {
                _addLog('切换目录错误: $e\n堆栈: $s');
              }
            }
          : () async {
              // 打开文件
              _openFile(item.path);
            },
      onLongPress: () => _showBottomActions(item),
    );
  }

  Future<void> _showBottomActions(FileInfo item) {
    return showModalBottomSheet(
      context: context,
      builder: (BuildContext context) {
        return SafeArea(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ListTile(
                leading: const Icon(Icons.info_outline),
                title: const Text('详细信息'),
                onTap: () {
                  Navigator.pop(context);
                  _showItemInfo(item);
                },
              ),
              ListTile(
                leading: const Icon(Icons.content_cut),
                title: const Text('剪切'),
                onTap: () {
                  Navigator.pop(context);
                  _cutItemAction(item);
                },
              ),
              ListTile(
                leading: const Icon(Icons.content_copy),
                title: const Text('复制'),
                onTap: () {
                  Navigator.pop(context);
                  _copyItemAction(item);
                },
              ),
              ListTile(
                leading: const Icon(Icons.delete, color: Colors.red),
                title: const Text('删除', style: TextStyle(color: Colors.red)),
                onTap: () async {
                  Navigator.pop(context);
                  final confirmed = await showDialog<bool>(
                        context: context,
                        builder: (context) => AlertDialog(
                          title: const Text('确认删除'),
                          content: Text(
                              '确定要删除${item.isFile ? '文件' : '目录'} "${item.name}" 吗?'),
                          actions: [
                            TextButton(
                              onPressed: () => Navigator.pop(context, false),
                              child: const Text('取消'),
                            ),
                            TextButton(
                              onPressed: () => Navigator.pop(context, true),
                              child: const Text('确定'),
                            ),
                          ],
                        ),
                      ) ??
                      false;

                  if (confirmed) {
                    try {
                      if (item.isFile) {
                        await storage.deleteFile(item.path);
                      } else {
                        await storage.deleteDirectory(item.path,
                            recursive: true);
                      }
                      _addLog('删除${item.isFile ? '文件' : '目录'}: ${item.name}');
                      await _loadCurrentDirectory();
                    } catch (e) {
                      _addLog('删除失败: $e');
                      if (!mounted) return;
                      await _showErrorDialog('删除失败: $e');
                    }
                  }
                },
              ),
            ],
          ),
        );
      },
    );
  }

  Future<void> _openFile(String filePath) async {
    try {
      _addLog('尝试打开文件: $filePath');

      // 检查文件是否存在
      final file = File(filePath);
      if (!await file.exists()) {
        _addLog('文件不存在: $filePath');
        if (!mounted) return;
        await showDialog(
          context: context,
          builder: (context) => AlertDialog(
            title: const Text('错误'),
            content: const Text('文件不存在'),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(context),
                child: const Text('确定'),
              ),
            ],
          ),
        );
        return;
      }

      // 尝试获取文件类型
      final mimeType = lookupMimeType(filePath);
      _addLog('文件类型: ${mimeType ?? "未知"}');

      // 打开文件
      final result = await OpenFile.open(
        filePath,
        type: mimeType,
      );

      if (!mounted) return;

      switch (result.type) {
        case ResultType.done:
          _addLog('文件打开成功');
          break;
        case ResultType.fileNotFound:
          _addLog('文件未找到');
          await _showErrorDialog('文件未找到');
          break;
        case ResultType.noAppToOpen:
          _addLog('没有找到可以打开此类型文件的应用');
          await _showErrorDialog('没有找到可以打开此类型文件的应用,请安装相关应用后重试');
          break;
        case ResultType.permissionDenied:
          _addLog('权限被拒绝');
          await _showErrorDialog('没有权限打开文件');
          break;
        case ResultType.error:
          _addLog('打开文件错误: ${result.message}');
          await _showErrorDialog('打开文件失败: ${result.message}');
          break;
      }
    } catch (e, s) {
      _addLog('打开文件异常: $e\n$s');
      if (!mounted) return;
      await _showErrorDialog('打开文件时发生错误: $e');
    }
  }

  Future<void> _showErrorDialog(String message) async {
    if (!mounted) return;
    return showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('错误'),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }

  Future<bool> _handleBackNavigation() async {
    if (_currentPath.isNotEmpty) {
      final isRootPath = _devices.any((device) => device.path == _currentPath);

      if (!isRootPath) {
        await storage.stopWatching(_currentPath);
        _addLog('停止监听目录: $_currentPath');

        final parentDir = path.dirname(_currentPath);
        if (parentDir != _currentPath) {
          if (!mounted) return false;
          setState(() {
            _currentPath = parentDir;
            _loadCurrentDirectory();
          });
          return false;
        }
      } else {
        await storage.stopWatching(_currentPath);
        _addLog('停止监听目录: $_currentPath');

        if (!mounted) return false;
        setState(() {
          _currentPath = '';
        });
        return false;
      }
    }
    return true;
  }

  Future<bool> _showExitDialog(BuildContext context) {
    return showDialog<bool>(
      context: context,
      builder: (dialogContext) => AlertDialog(
        title: const Text('退出应用'),
        content: const Text('确定要退出应用吗?'),
        actions: <Widget>[
          TextButton(
            onPressed: () => Navigator.of(dialogContext).pop(false),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.of(dialogContext).pop(true),
            child: const Text('确定'),
          ),
        ],
      ),
    ).then((value) => value ?? false);
  }

  // 实现复制方法
  Future<void> _copyItemAction(FileInfo item) async {
    setState(() {
      _copyItem = item;
      _isCopyOperationActive = true;
    });
    _addLog('复制: ${item.path}');
  }

  // 实现粘贴方法
  Future<void> _pasteCopiedItem() async {
    if (_copyItem == null) return;

    try {
      String newPath = path.join(_currentPath, _copyItem!.name);
      String originalName = path.basenameWithoutExtension(_copyItem!.name);
      String extension = path.extension(_copyItem!.name);
      int counter = 1;

      // 检查目标路径是否存在,如果存在则添加数字后缀
      while (await storage.fileExists(newPath) ||
          await storage.directoryExists(newPath)) {
        newPath = path.join(_currentPath, '${originalName}_$counter$extension');
        counter++;
      }

      if (_copyItem!.isFile) {
        await storage.copyFile(_copyItem!.path, newPath);
      } else {
        await storage.copyDirectory(_copyItem!.path, newPath);
      }
      _addLog('粘贴: ${_copyItem!.path} 到 $newPath');
      await _loadCurrentDirectory();
    } catch (e, s) {
      _addLog('粘贴错误: $e\n堆栈: $s');
      if (!mounted) return;
      await _showErrorDialog('粘贴失败: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (didPop, result) async {
        if (!didPop) {
          final shouldShowExitDialog = await _handleBackNavigation();
          if (shouldShowExitDialog) {
            final navContext = context;
            if (!navContext.mounted) return;

            final shouldExit = await _showExitDialog(navContext);
            if (shouldExit && navContext.mounted) {
              Navigator.of(navContext).pop();
            }
          }
        }
      },
      child: Scaffold(
        appBar: AppBar(
          title: const Text('External Storage Demo'),
          actions: [
            IconButton(
              icon: const Icon(Icons.home),
              onPressed: () {
                setState(() {
                  _currentPath = '';
                });
              },
            ),
            IconButton(
              icon: const Icon(Icons.refresh),
              onPressed: _loadCurrentDirectory,
            ),
          ],
        ),
        body: Column(
          children: [
            Container(
              padding: const EdgeInsets.all(8.0),
              color:
                  _hasPermission ? Colors.green.shade100 : Colors.red.shade100,
              child: Row(
                children: [
                  Icon(
                    _hasPermission ? Icons.check_circle : Icons.error,
                    color: _hasPermission ? Colors.green : Colors.red,
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      '存储权限状态: $_permissionStatus',
                      style: TextStyle(
                        color: _hasPermission
                            ? Colors.green.shade900
                            : Colors.red.shade900,
                      ),
                    ),
                  ),
                  if (!_hasPermission)
                    ElevatedButton(
                      onPressed: () async {
                        await _initializeStorage();
                      },
                      child: const Text('请求权限'),
                    ),
                ],
              ),
            ),
            Expanded(
              child: _hasPermission
                  ? _currentPath.isEmpty
                      ? _buildDeviceList()
                      : Column(
                          children: [
                            Padding(
                              padding: const EdgeInsets.all(8.0),
                              child: Text(
                                '当前路径: $_currentPath',
                                style: Theme.of(context).textTheme.bodyMedium,
                              ),
                            ),
                            Expanded(
                              child: _isLoading
                                  ? const Center(
                                      child: CircularProgressIndicator())
                                  : _currentEntries.isEmpty
                                      ? const Center(child: Text('当前目录为空'))
                                      : ListView.builder(
                                          itemCount: _currentEntries.length,
                                          itemBuilder: (context, index) =>
                                              _buildListItem(
                                                  _currentEntries[index]),
                                        ),
                            ),
                            Container(
                              height: 150,
                              decoration: BoxDecoration(
                                border: Border(
                                  top: BorderSide(
                                    color: Theme.of(context).dividerColor,
                                  ),
                                ),
                              ),
                              child: ListView.builder(
                                reverse: true,
                                itemCount: _logs.length,
                                itemBuilder: (context, index) => Padding(
                                  padding: const EdgeInsets.symmetric(
                                      horizontal: 8.0, vertical: 2.0),
                                  child: Text(
                                    _logs[index],
                                    style:
                                        Theme.of(context).textTheme.bodySmall,
                                  ),
                                ),
                              ),
                            ),
                          ],
                        )
                  : const Center(child: Text('需要存储权限才能使用此应用')),
            ),
          ],
        ),
        floatingActionButton: _hasPermission && _currentPath.isNotEmpty
            ? Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  if (_isCutOperationActive) ...[
                    FloatingActionButton(
                      onPressed: _pasteItem,
                      tooltip: '粘贴',
                      child: const Icon(Icons.paste),
                    ),
                    const SizedBox(width: 16),
                    FloatingActionButton(
                      onPressed: _cancelCut,
                      tooltip: '取消',
                      child: const Icon(Icons.cancel),
                    ),
                  ] else if (_isCopyOperationActive) ...[
                    FloatingActionButton(
                      onPressed: _pasteCopiedItem,
                      tooltip: '粘贴',
                      child: const Icon(Icons.paste),
                    ),
                    const SizedBox(width: 16),
                  ],
                  FloatingActionButton(
                    onPressed: _createTestFile,
                    tooltip: '创建测试文件',
                    child: const Icon(Icons.note_add),
                  ),
                  const SizedBox(width: 16),
                  FloatingActionButton(
                    onPressed: _createTestDirectory,
                    tooltip: '创建测试目录',
                    child: const Icon(Icons.create_new_folder),
                  ),
                ],
              )
            : null,
      ),
    );
  }

  @override
  void dispose() {
    storage.unregisterWatchEventCallback();
    storage.stopAllWatching();
    super.dispose();
  }
}
2
likes
145
points
1
downloads

Publisher

verified publisherflutter-online.top

Weekly Downloads

A powerful Flutter Android external storage management plugin that provides complete file system operations, storage device management, file monitoring, and permission handling functionality.

Documentation

API reference

License

MIT (license)

Dependencies

flutter, path, plugin_platform_interface

More

Packages that depend on external_storage