264 lines
10 KiB
Dart
264 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
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 - 시험에서 바꿈)
|
|
bool Function() shizukuSupported = () => Platform.isAndroid;
|
|
|
|
/// 탐색기 탭이 처음 여는 곳 (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';
|
|
|
|
/// 루트 보기를 쓰는 기기 (Android - 시험에서 바꿈)
|
|
bool Function() rootSupported = () => Platform.isAndroid;
|
|
|
|
/// 루트 탭이 처음 여는 곳 (관리자 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 {
|
|
/// 프로세스 시작 (시험에서 바꿈)
|
|
static Future<Process> Function(String exe, List<String> args) start = (exe, args) => Process.start(exe, args);
|
|
|
|
static bool _granted = false;
|
|
|
|
@visibleForTesting
|
|
static void resetForTest() => _granted = false;
|
|
|
|
Future<(int, String, String)> _run(String exe, List<String> 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<String> _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<bool> 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<String> list(String path) => _text(shellListScript(path));
|
|
|
|
@override
|
|
Future<String> stat(String path) async {
|
|
try {
|
|
return await _text(shellStatScript(path));
|
|
} on FileSystemException {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<int> 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<bool> request();
|
|
|
|
/// "종류|크기|바뀐 때 (초)|이름" 줄들
|
|
Future<String> list(String path);
|
|
|
|
/// 한 항목의 "종류|크기|바뀐 때|이름" (없으면 빈 글)
|
|
Future<String> stat(String path);
|
|
|
|
/// 내용을 [dest] 에 복사 (바이트 수)
|
|
Future<int> 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<Map<Object?, Object?>>('shizukuStatus') ?? const {};
|
|
return (running: m['running'] == true, granted: m['granted'] == true);
|
|
}
|
|
|
|
@override
|
|
Future<bool> request() async => await _ch.invokeMethod<bool>('shizukuRequest') ?? false;
|
|
|
|
@override
|
|
Future<String> list(String path) async => await _ch.invokeMethod<String>('shizukuList', {'path': path}) ?? '';
|
|
|
|
@override
|
|
Future<String> stat(String path) async => await _ch.invokeMethod<String>('shizukuStat', {'path': path}) ?? '';
|
|
|
|
@override
|
|
Future<int> copyTo(String path, String dest) async =>
|
|
(await _ch.invokeMethod<num>('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);
|
|
}
|
|
|
|
/// [RemoteClient] 로 탐색기 · 복사 (꺼내기) · 보기가 그대로 쓰게. rel = 기기 절대 경로
|
|
class ShizukuRemote implements RemoteClient {
|
|
static ShizukuBackend backend = AndroidShizuku();
|
|
|
|
/// 루트 (su) 쪽 - 시험에서 바꿈
|
|
static ShizukuBackend rootBackend = SuRoot();
|
|
|
|
/// 내용을 잠깐 받아 둘 앱 전용 폴더 (시험에서 바꿈)
|
|
static Future<String> Function() tempDir = () async => Directory.systemTemp.path;
|
|
|
|
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 ? rootBackend : backend;
|
|
|
|
String _abs(String rel) => rel.isEmpty ? '/' : rel;
|
|
|
|
@override
|
|
Future<List<DavItem>> list(String rel) async {
|
|
final out = <DavItem>[];
|
|
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<DavItem?> 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<Stream<List<int>>> openRead(String rel) async {
|
|
final dir = Directory(p.join(await 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<List<int>>();
|
|
ctl.addStream(f.openRead()).whenComplete(() async {
|
|
await ctl.close();
|
|
await dir.delete(recursive: true).catchError((_) => dir);
|
|
});
|
|
return ctl.stream;
|
|
}
|
|
|
|
@override
|
|
Future<void> mkdirs(String rel) async => throw ShizukuReadOnlyException(rel);
|
|
@override
|
|
Future<void> delete(String rel) async => throw ShizukuReadOnlyException(rel);
|
|
@override
|
|
Future<void> move(String from, String to, {bool overwrite = false}) async => throw ShizukuReadOnlyException(from);
|
|
@override
|
|
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async => throw ShizukuReadOnlyException(rel);
|
|
@override
|
|
Future<(int?, int?)> quota() async => (null, null);
|
|
@override
|
|
void close() {}
|
|
}
|