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
parent
18d003f392
commit
519877105c
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue