import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'remote_fs.dart'; import 'webdav.dart' show DavItem; /// X-plore 비교 5-2 시스템 폴더 보기 (사용자 결정 10/21, 관리자 결정: 1단계는 Shizuku 만 · **읽기 전용** · 처음 끔). /// 경로: `shz://sys/<기기 절대 경로>` (예 shz://sys/storage/emulated/0/Android/data). 목록 · 정보 · 내용 읽기만 하고, /// 쓰기 · 지우기 · 옮기기는 [ShizukuReadOnlyException] (탐색기도 그 전에 막는다 - 압축 안처럼) const shizukuScheme = 'shz://'; const shizukuServer = 'sys'; bool isShizuku(String path) => path.startsWith(shizukuScheme); /// 탐색기 탭이 처음 여는 곳 (Android 11 부터 막힌 Android/data · obb 가 있는 폴더) String shizukuHome(String storageRoot) => '$shizukuScheme$shizukuServer${storageRoot.replaceAll('\\', '/')}/Android'; /// X-plore 비교 5-2 루트 (관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 경고 두 번 · 주황 띠 · 고정 명령 셋 · 쓰기 길 없음). /// 경로: `shz://root/<기기 절대 경로>` - 읽기 전용 막기를 시스템 폴더와 같이 쓴다 const rootServer = 'root'; bool isRootPath(String path) => path.startsWith('$shizukuScheme$rootServer/') || path == '$shizukuScheme$rootServer'; // 시스템 폴더 · 루트 보기를 쓰는 기기인지는 PlatformInfo.isAndroid (c.services.platform - 예전 전역 함수 shizukuSupported · rootSupported) /// 루트 탭이 처음 여는 곳 (관리자 3a 결정 10/21: "/" - 다른 앱의 개인 자료가 있는 /data 를 고르지 않았는데 펼치지 않게) const rootHome = '$shizukuScheme$rootServer/'; /// 셸에 넘기는 경로 (절대 경로만 · 줄바꿈 · \0 없음) 를 작은따옴표로 감싼다 - 사용자 글을 명령으로 넣지 않게 String shellPath(String path) { if (!path.startsWith('/') || path.contains('\n') || path.contains('\u0000')) throw ArgumentError.value(path, 'path'); return "'${path.replaceAll("'", "'\\''")}'"; } /// 고정 명령 셋 (Kotlin ShizukuBridge 와 같은 것): 목록 · 한 항목 정보 · 내용 읽기 String shellListScript(String path) => 'cd ${shellPath(path)} && for f in * .[!.]* ..?*; do [ -e "\$f" ] || [ -L "\$f" ] || continue; stat -c \'%F|%s|%Y|%n\' -- "\$f"; done'; String shellStatScript(String path) => "stat -c '%F|%s|%Y|%n' -- ${shellPath(path)}"; String shellCatScript(String path) => 'cat -- ${shellPath(path)}'; /// su 로 (루팅한 기기). 이번 실행에서 허락을 받았는지만 기억한다 (허락 · 거절은 Magisk 같은 su 앱이 기억) class SuRoot implements ShizukuBackend { /// 프로세스 시작 (처음 값 = 실제 Process.start - 시험은 가짜 su 를 넘긴다) final Future Function(String exe, List args) start; SuRoot({Future Function(String exe, List args)? start}) : start = start ?? Process.start; static bool _granted = false; /// 이번 실행에서 받은 허락을 잊는다 (다음 요청에서 su 에 다시 묻는다) static void forgetGrant() => _granted = false; Future<(int, String, String)> _run(String exe, List args, {Duration timeout = const Duration(seconds: 60)}) async { final p = await start(exe, args); unawaited(p.stdin.close()); final out = p.stdout.transform(const Utf8Decoder(allowMalformed: true)).join(); final err = p.stderr.transform(const Utf8Decoder(allowMalformed: true)).join(); final code = await p.exitCode.timeout(timeout, onTimeout: () { p.kill(); return -1; }); return (code, await out, await err); } Future _text(String script) async { final (code, out, err) = await _run('su', ['-c', script]); if (code != 0 && out.isEmpty) throw FileSystemException(err.trim().isEmpty ? 'exit $code' : err.trim()); return out; } /// running = su 가 있는지 (묻는 창은 아직 띄우지 않음) @override Future<({bool running, bool granted})> status() async { try { final (code, out, _) = await _run('sh', ['-c', 'command -v su'], timeout: const Duration(seconds: 10)); return (running: code == 0 && out.trim().isNotEmpty, granted: _granted); } catch (_) { return (running: false, granted: false); } } /// su 앱의 허락 창 (id -u 가 0 이면 허락) @override Future request() async { try { final (code, out, _) = await _run('su', ['-c', 'id -u']); return _granted = code == 0 && out.trim() == '0'; } catch (_) { return _granted = false; } } @override Future list(String path) => _text(shellListScript(path)); @override Future stat(String path) async { try { return await _text(shellStatScript(path)); } on FileSystemException { return ''; } } @override Future copyTo(String path, String dest) async { final p = await start('su', ['-c', shellCatScript(path)]); unawaited(p.stdin.close()); final err = p.stderr.transform(const Utf8Decoder(allowMalformed: true)).join(); final f = File(dest); final sink = f.openWrite(); try { await sink.addStream(p.stdout); } finally { await sink.close(); } final code = await p.exitCode; if (code != 0) { await f.delete().catchError((_) => f); final e = (await err).trim(); throw FileSystemException(e.isEmpty ? 'exit $code' : e, path); } return f.length(); } } /// 기기와 이야기하는 쪽 (시험에서 바꿔 끼움) abstract class ShizukuBackend { /// Shizuku 가 켜져 있는지 · 이 앱이 허락을 받았는지 Future<({bool running, bool granted})> status(); /// Shizuku 허락 창 (허락하면 true) Future request(); /// "종류|크기|바뀐 때 (초)|이름" 줄들 Future list(String path); /// 한 항목의 "종류|크기|바뀐 때|이름" (없으면 빈 글) Future stat(String path); /// 내용을 [dest] 에 복사 (바이트 수) Future copyTo(String path, String dest); } class AndroidShizuku implements ShizukuBackend { static const _ch = MethodChannel('jj_mkvmaker/android'); @override Future<({bool running, bool granted})> status() async { final m = await _ch.invokeMethod>('shizukuStatus') ?? const {}; return (running: m['running'] == true, granted: m['granted'] == true); } @override Future request() async => await _ch.invokeMethod('shizukuRequest') ?? false; @override Future list(String path) async => await _ch.invokeMethod('shizukuList', {'path': path}) ?? ''; @override Future stat(String path) async => await _ch.invokeMethod('shizukuStat', {'path': path}) ?? ''; @override Future copyTo(String path, String dest) async => (await _ch.invokeMethod('shizukuCopy', {'path': path, 'dest': dest}))?.toInt() ?? 0; } class ShizukuReadOnlyException extends FileSystemException { ShizukuReadOnlyException(String path) : super(tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다'), path); } /// toybox `stat -c '%F|%s|%Y|%n'` 한 줄 → (폴더인지, 크기, 바뀐 때, 이름). 틀리면 null (bool, int, DateTime, String)? parseShizukuStat(String line) { final parts = line.split('|'); if (parts.length < 4) return null; final name = parts.sublist(3).join('|'); // 이름에 | 가 있어도 final size = int.tryParse(parts[1].trim()); final secs = int.tryParse(parts[2].trim()); if (size == null || secs == null || name.isEmpty) return null; return (parts[0].trim() == 'directory', size, DateTime.fromMillisecondsSinceEpoch(secs * 1000), name); } /// 약속 (M3 PrivilegedFs): 시스템 폴더 (Shizuku) · 루트 (su) 읽기의 뒤쪽 둘과 내용을 잠깐 받아 둘 폴더 - 한 곳에서 정한다. /// [device] = 실제 (AndroidShizuku · su, 임시는 넘긴 폴더). 앱은 켤 때 PlatformServices.privileged 를 [use] 로 건다 (원격 서버 목록처럼 한 번). /// 시험은 [use] 로 가짜 뒤쪽을 걸고 끝에 되돌린다. 예전의 ShizukuRemote.backend · rootBackend · tempDir · SuRoot.start (따로 바꾸던 static 넷) 를 대신한다 class PrivilegedFs { final ShizukuBackend shizuku; final ShizukuBackend root; /// 내용을 잠깐 받아 둘 폴더 final Future Function() tempDir; const PrivilegedFs({required this.shizuku, required this.root, required this.tempDir}); factory PrivilegedFs.device(Future Function() tempDir) => PrivilegedFs(shizuku: AndroidShizuku(), root: SuRoot(), tempDir: tempDir); /// 지금 쓰는 것 (처음 값 = 실제 뒤쪽 · 시스템 임시 폴더) static PrivilegedFs current = PrivilegedFs.device(() async => Directory.systemTemp.path); static void use(PrivilegedFs fs) => current = fs; } /// [RemoteClient] 로 탐색기 · 복사 (꺼내기) · 보기가 그대로 쓰게. rel = 기기 절대 경로. 뒤쪽 · 임시 폴더는 [PrivilegedFs.current] class ShizukuRemote implements RemoteClient { static final instance = ShizukuRemote(); static final root = ShizukuRemote(isRoot: true); /// 루트 (shz://root/…) 인지 - 아니면 Shizuku (shz://sys/…) final bool isRoot; ShizukuRemote({this.isRoot = false}); ShizukuBackend get _b => isRoot ? PrivilegedFs.current.root : PrivilegedFs.current.shizuku; String _abs(String rel) => rel.isEmpty ? '/' : rel; @override Future> list(String rel) async { final out = []; for (final line in (await _b.list(_abs(rel))).split('\n')) { final s = parseShizukuStat(line.trimRight()); if (s == null) continue; final (dir, size, modified, name) = s; if (name == '.' || name == '..') continue; out.add(DavItem(rel == '/' ? '/$name' : '$rel/$name', isDir: dir, size: dir ? 0 : size, modified: modified)); } return out; } @override Future stat(String rel) async { final s = parseShizukuStat((await _b.stat(_abs(rel))).trim()); if (s == null) return null; return DavItem(rel, isDir: s.$1, size: s.$1 ? 0 : s.$2, modified: s.$3); } /// 앱 전용 임시 폴더에 받아 두고 읽은 뒤 지운다 @override Future>> openRead(String rel) async { final dir = Directory(p.join(await PrivilegedFs.current.tempDir(), 'jj_shz_${DateTime.now().microsecondsSinceEpoch}')); await dir.create(recursive: true); final f = File(p.join(dir.path, 'f')); try { await _b.copyTo(_abs(rel), f.path); } catch (_) { await dir.delete(recursive: true).catchError((_) => dir); rethrow; } final ctl = StreamController>(); ctl.addStream(f.openRead()).whenComplete(() async { await ctl.close(); await dir.delete(recursive: true).catchError((_) => dir); }); return ctl.stream; } @override Future mkdirs(String rel) async => throw ShizukuReadOnlyException(rel); @override Future delete(String rel) async => throw ShizukuReadOnlyException(rel); @override Future move(String from, String to, {bool overwrite = false}) async => throw ShizukuReadOnlyException(from); @override Future write(String rel, Stream> data, {int? length}) async => throw ShizukuReadOnlyException(rel); @override Future<(int?, int?)> quota() async => (null, null); @override void close() {} }