JJ_MKVMaker/lib/core/shizuku.dart

271 lines
11 KiB
Dart

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<Process> Function(String exe, List<String> args) start;
SuRoot({Future<Process> Function(String exe, List<String> args)? start}) : start = start ?? Process.start;
static bool _granted = false;
/// 이번 실행에서 받은 허락을 잊는다 (다음 요청에서 su 에 다시 묻는다)
static void forgetGrant() => _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);
}
/// 약속 (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<String> Function() tempDir;
const PrivilegedFs({required this.shizuku, required this.root, required this.tempDir});
factory PrivilegedFs.device(Future<String> 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<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 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<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() {}
}