Android: 외장 SD 카드 · USB 메모리 선택

- 앱 안 파일 고르기: 바로 가기에 저장소 (내장 · SD 카드 · USB) 를 보이고, 그 안에서 폴더 이동 · 위로 가기
- 설정 > 저장 위치 (MKV · 자막, 다운로드): Android 는 앱 안 폴더 고르기 (SD 카드 폴더를 실제 경로로)
- [폴더 열기] · [결과 폴더]: SD 카드 경로도 파일 앱으로 (문서 ID XXXX-XXXX:경로)
- MainActivity.storageVolumes: StorageManager 의 연결된 저장소 목록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-03 03:44:08 +09:00
parent 18d003f392
commit 519877105c
4 changed files with 140 additions and 37 deletions

View File

@ -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<String>("url") ?: ""))
"installApk" -> try {
installApk(call.argument<String>("path") ?: "")
@ -82,6 +83,33 @@ class MainActivity : FlutterActivity() {
)
}
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
private fun storageVolumes(): List<Map<String, Any>> {
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager
val out = mutableListOf<Map<String, Any>>()
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 {

View File

@ -28,6 +28,21 @@ class AndroidAccess {
} catch (_) {}
}
/// 쓸 수 있는 저장소: (경로, 이름, 빼낼 수 있는지). 내장 저장소가 먼저.
/// 읽지 못하면 내장 저장소만.
static Future<List<(String, String, bool)>> volumes() async {
try {
final list = await _ch.invokeMethod<List<Object?>>('storageVolumes') ?? const [];
final out = [
for (final v in list.cast<Map<Object?, Object?>>())
(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<String> storageRoot() async {
try {

View File

@ -10,6 +10,7 @@ import 'theme.dart';
String? _lastDir;
/// Android 앱 안 파일 고르기: 폴더를 오가며 [extensions] 파일을 여러 개 고른다. 고른 파일의 실제 경로를 돌려준다.
/// 내장 저장소와 SD 카드 · USB 메모리를 오갈 수 있다.
Future<List<String>?> showAndroidFileBrowser(
BuildContext context, {
required String title,
@ -21,18 +22,31 @@ Future<List<String>?> showAndroidFileBrowser(
builder: (_) => _FileBrowser(title: title, extensions: extensions, initialDirectory: initialDirectory),
));
/// Android 앱 안 폴더 고르기 (저장 위치 등). 고른 폴더의 실제 경로, 취소하면 null.
Future<String?> showAndroidFolderBrowser(BuildContext context, {required String title, String? initialDirectory}) async {
final r = await Navigator.of(context).push<List<String>>(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<String> 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<Directory> _dirs = [];
@ -46,15 +60,25 @@ class _FileBrowserState extends State<_FileBrowser> {
}
Future<void> _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(

View File

@ -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<SettingsPage> {
super.dispose();
}
Future<String?> _pickDir(String title) => FilePicker.getDirectoryPath(dialogTitle: title);
/// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음)
Future<String?> _pickDir(String title, [String? initial]) => Platform.isAndroid
? showAndroidFolderBrowser(context, title: title, initialDirectory: initial)
: FilePicker.getDirectoryPath(dialogTitle: title, initialDirectory: initial);
Future<void> _applyHotkey() async {
final text = _hotkey.text.trim();
@ -388,7 +392,7 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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),