diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index b569806..c12227a 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -27,6 +27,7 @@ class MainActivity : FlutterActivity() { result.success(null) } "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + "storageVolumes" -> result.success(storageVolumes()) "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) "installApk" -> try { installApk(call.argument("path") ?: "") @@ -82,6 +83,33 @@ class MainActivity : FlutterActivity() { ) } + /// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] + private fun storageVolumes(): List> { + val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager + val out = mutableListOf>() + for (v in sm.storageVolumes) { + if (v.state != Environment.MEDIA_MOUNTED) continue + val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + v.directory + } else { + try { + v.javaClass.getMethod("getPathFile").invoke(v) as File? + } catch (e: Exception) { + null + } + } + if (dir == null) continue + out.add( + mapOf( + "path" to dir.absolutePath, + "label" to (v.getDescription(this) ?: dir.name), + "removable" to v.isRemovable, + ) + ) + } + return out + } + /// 웹 주소를 기기의 기본 브라우저로 private fun openUrl(url: String): Boolean = try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) @@ -92,9 +120,16 @@ class MainActivity : FlutterActivity() { /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. private fun openFolder(path: String): Boolean { + // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") val root = Environment.getExternalStorageDirectory().path - if (!path.startsWith(root)) return false - val rel = path.removePrefix(root).trim('/') + val (volumeId, rel) = when { + path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/') + path.startsWith("/storage/") -> { + val rest = path.removePrefix("/storage/") + rest.substringBefore('/') to rest.substringAfter('/', "").trim('/') + } + else -> return false + } // 1. 삼성 "내 파일": 그 폴더로 바로 연다 try { startActivity( @@ -108,7 +143,7 @@ class MainActivity : FlutterActivity() { } // 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다 val uri = android.provider.DocumentsContract.buildDocumentUri( - "com.android.externalstorage.documents", "primary:$rel" + "com.android.externalstorage.documents", "$volumeId:$rel" ) for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) { try { diff --git a/lib/platform/android/android_storage.dart b/lib/platform/android/android_storage.dart index e423898..8999a9c 100644 --- a/lib/platform/android/android_storage.dart +++ b/lib/platform/android/android_storage.dart @@ -28,6 +28,21 @@ class AndroidAccess { } catch (_) {} } + /// 쓸 수 있는 저장소: (경로, 이름, 빼낼 수 있는지). 내장 저장소가 먼저. + /// 읽지 못하면 내장 저장소만. + static Future> volumes() async { + try { + final list = await _ch.invokeMethod>('storageVolumes') ?? const []; + final out = [ + for (final v in list.cast>()) + (v['path'] as String, v['label'] as String? ?? '', v['removable'] == true), + ]; + out.sort((a, b) => (a.$3 ? 1 : 0) - (b.$3 ? 1 : 0)); + if (out.isNotEmpty) return out; + } catch (_) {} + return [(await storageRoot(), '내장 저장소', false)]; + } + /// 내장 저장소 맨 위 (예: /storage/emulated/0) static Future storageRoot() async { try { diff --git a/lib/ui/android_file_browser.dart b/lib/ui/android_file_browser.dart index 7a74116..1ac550f 100644 --- a/lib/ui/android_file_browser.dart +++ b/lib/ui/android_file_browser.dart @@ -10,6 +10,7 @@ import 'theme.dart'; String? _lastDir; /// Android 앱 안 파일 고르기: 폴더를 오가며 [extensions] 파일을 여러 개 고른다. 고른 파일의 실제 경로를 돌려준다. +/// 내장 저장소와 SD 카드 · USB 메모리를 오갈 수 있다. Future?> showAndroidFileBrowser( BuildContext context, { required String title, @@ -21,18 +22,31 @@ Future?> showAndroidFileBrowser( builder: (_) => _FileBrowser(title: title, extensions: extensions, initialDirectory: initialDirectory), )); +/// Android 앱 안 폴더 고르기 (저장 위치 등). 고른 폴더의 실제 경로, 취소하면 null. +Future showAndroidFolderBrowser(BuildContext context, {required String title, String? initialDirectory}) async { + final r = await Navigator.of(context).push>(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _FileBrowser(title: title, extensions: const [], initialDirectory: initialDirectory, folder: true), + )); + return r?.firstOrNull; +} + class _FileBrowser extends StatefulWidget { final String title; final List extensions; final String? initialDirectory; - const _FileBrowser({required this.title, required this.extensions, this.initialDirectory}); + + /// 폴더 고르기 (파일은 보이지 않고 "이 폴더 선택") + final bool folder; + const _FileBrowser({required this.title, required this.extensions, this.initialDirectory, this.folder = false}); @override State<_FileBrowser> createState() => _FileBrowserState(); } class _FileBrowserState extends State<_FileBrowser> { - String _root = '/storage/emulated/0'; + /// (경로, 이름, 빼낼 수 있는지) + List<(String, String, bool)> _volumes = const [('/storage/emulated/0', '내장 저장소', false)]; String? _dir; bool? _allowed; List _dirs = []; @@ -46,15 +60,25 @@ class _FileBrowserState extends State<_FileBrowser> { } Future _start() async { - _root = await AndroidAccess.storageRoot(); + _volumes = await AndroidAccess.volumes(); final allowed = await AndroidAccess.hasAllFiles(); - final first = [widget.initialDirectory, _lastDir, _root].firstWhere( - (d) => d != null && Directory(d).existsSync(), - orElse: () => _root)!; + final first = [widget.initialDirectory, _lastDir, _volumes.first.$1].firstWhere( + (d) => d != null && Directory(d).existsSync() && _volumeOf(d) != null, + orElse: () => _volumes.first.$1)!; setState(() => _allowed = allowed); if (allowed) _open(first); } + /// [dir] 이 들어 있는 저장소 (맨 위 폴더 · 이름) + (String, String, bool)? _volumeOf(String dir) { + for (final v in _volumes) { + if (p.equals(v.$1, dir) || p.isWithin(v.$1, dir)) return v; + } + return null; + } + + static String _volumeName((String, String, bool) v) => v.$3 ? (v.$2.isEmpty ? 'SD 카드' : v.$2) : '내장 저장소'; + bool _match(String path) => widget.extensions.contains(p.extension(path).replaceFirst('.', '').toLowerCase()); void _open(String dir) { @@ -65,7 +89,7 @@ class _FileBrowserState extends State<_FileBrowser> { final name = p.basename(e.path); if (name.startsWith('.')) continue; if (e is Directory) dirs.add(e); - if (e is File && _match(e.path)) files.add(e); + if (!widget.folder && e is File && _match(e.path)) files.add(e); } } catch (_) {} int byName(FileSystemEntity a, FileSystemEntity b) => @@ -88,7 +112,10 @@ class _FileBrowserState extends State<_FileBrowser> { @override Widget build(BuildContext context) { final dir = _dir; - final atRoot = dir == null || p.equals(dir, _root) || !p.isWithin(_root, dir); + final vol = dir == null ? null : _volumeOf(dir); + final root = vol?.$1; + final atRoot = dir == null || root == null || p.equals(dir, root); + final internal = _volumes.first.$1; return PopScope( canPop: atRoot, onPopInvokedWithResult: (didPop, _) { @@ -98,45 +125,63 @@ class _FileBrowserState extends State<_FileBrowser> { appBar: AppBar( title: Text(widget.title), actions: [ - if (_files.isNotEmpty) - TextButton( - onPressed: () => setState(() { - final all = _files.every((f) => _picked.contains(f.path)); - for (final f in _files) { - all ? _picked.remove(f.path) : _picked.add(f.path); - } - }), - child: const Text('이 폴더 전체'), + if (widget.folder) + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton.icon( + onPressed: dir == null ? null : () => Navigator.pop(context, [dir]), + icon: const Icon(Icons.check, size: 18), + label: const Text('이 폴더 선택'), + ), + ) + else ...[ + if (_files.isNotEmpty) + TextButton( + onPressed: () => setState(() { + final all = _files.every((f) => _picked.contains(f.path)); + for (final f in _files) { + all ? _picked.remove(f.path) : _picked.add(f.path); + } + }), + child: const Text('이 폴더 전체'), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton( + onPressed: _picked.isEmpty ? null : () => Navigator.pop(context, _picked.toList()), + child: Text('추가 (${_picked.length})'), + ), ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: FilledButton( - onPressed: _picked.isEmpty ? null : () => Navigator.pop(context, _picked.toList()), - child: Text('추가 (${_picked.length})'), - ), - ), + ], ], ), body: switch (_allowed) { null => const Center(child: CircularProgressIndicator()), false => _permission(), true => Column(children: [ - // 바로 가기 · 지금 폴더 + // 바로 가기: 저장소 (내장 · SD 카드 · USB) → 내장 저장소의 자주 쓰는 폴더 SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), child: Row(children: [ + for (final v in _volumes) + Padding( + padding: const EdgeInsets.only(right: 6), + child: ActionChip( + avatar: Icon(v.$3 ? Icons.sd_card_outlined : Icons.phone_android, size: 18), + label: Text(_volumeName(v)), + onPressed: () => _open(v.$1), + ), + ), for (final (label, sub) in [ - ('내장 저장소', ''), ('Download', 'Download'), ('Movies', 'Movies'), ('DCIM', 'DCIM'), - ('jj_mkv', 'Movies/jj_mkv'), ]) - if (Directory(p.join(_root, sub)).existsSync()) + if (Directory(p.join(internal, sub)).existsSync()) Padding( padding: const EdgeInsets.only(right: 6), - child: ActionChip(label: Text(label), onPressed: () => _open(p.join(_root, sub))), + child: ActionChip(label: Text(label), onPressed: () => _open(p.join(internal, sub))), ), ]), ), @@ -148,14 +193,18 @@ class _FileBrowserState extends State<_FileBrowser> { onPressed: atRoot ? null : () => _open(p.dirname(dir)), ), title: Text( - dir == null ? '' : (p.equals(dir, _root) ? '내장 저장소' : p.relative(dir, from: _root)), + dir == null || vol == null + ? '' + : (atRoot ? _volumeName(vol) : '${_volumeName(vol)} / ${p.relative(dir, from: root)}'), style: const TextStyle(color: JjColors.textDim), ), ), const Divider(height: 1), Expanded( child: _dirs.isEmpty && _files.isEmpty - ? const Center(child: Text('이 폴더에는 고를 파일이 없습니다', style: TextStyle(color: JjColors.textDim))) + ? Center( + child: Text(widget.folder ? '하위 폴더가 없습니다' : '이 폴더에는 고를 파일이 없습니다', + style: const TextStyle(color: JjColors.textDim))) : ListView(children: [ for (final d in _dirs) ListTile( diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 4458fdf..f906b52 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -14,6 +14,7 @@ import 'setup_dialog.dart'; import 'update_dialog.dart'; import '../app/settings.dart'; import '../platform/windows/cef_runtime.dart'; +import 'android_file_browser.dart'; import 'app_actions.dart'; import 'cef_setup.dart'; import 'theme.dart'; @@ -221,7 +222,10 @@ class _SettingsPageState extends State { super.dispose(); } - Future _pickDir(String title) => FilePicker.getDirectoryPath(dialogTitle: title); + /// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음) + Future _pickDir(String title, [String? initial]) => Platform.isAndroid + ? showAndroidFolderBrowser(context, title: title, initialDirectory: initial) + : FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial); Future _applyHotkey() async { final text = _hotkey.text.trim(); @@ -388,7 +392,7 @@ class _SettingsPageState extends State { value: s.mkvOutputRoot, defaultText: '동영상이 있는 폴더 아래 jj_mkv (기본)', onPick: () async { - final d = await _pickDir('MKV · 자막 저장 위치'); + final d = await _pickDir('MKV · 자막 저장 위치', s.mkvOutputRoot); if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); }, onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), @@ -398,7 +402,7 @@ class _SettingsPageState extends State { value: s.downloadRoot, defaultText: '${desk ? '프로그램 폴더' : '내장 저장소'} (기본): ${s.resolvedDownloadRoot()}', onPick: () async { - final d = await _pickDir('다운로드 위치'); + final d = await _pickDir('다운로드 위치', s.resolvedDownloadRoot()); if (d != null) await c.updateSettings((x) => x.downloadRoot = d); }, onReset: () => c.updateSettings((x) => x.downloadRoot = null),