import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/core/shizuku.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:path/path.dart' as p; import 'support/test_explorer_device.dart'; /// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용 - 관리자 결정 10/21: Shizuku 와 같은 틀, 처음 여는 곳 "/") class _Proc implements Process { final int code; final List out; final String err; _Proc(this.code, this.out, [this.err = '']); @override Stream> get stdout => Stream.value(out); @override Stream> get stderr => Stream.value(utf8.encode(err)); @override IOSink get stdin => IOSink(StreamController>()..stream.listen((_) {})); @override Future get exitCode async => code; @override int get pid => 1; @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true; } /// 가짜 su: 정한 스크립트에만 답한다 class _Su { bool hasSu = true, allow = true; final calls = []; final answers = )>{}; Future start(String exe, List args) async { final s = args.last; calls.add('$exe $s'); if (exe == 'sh') return _Proc(hasSu ? 0 : 1, utf8.encode(hasSu ? '/system/bin/su\n' : '')); if (!hasSu) throw const ProcessException('su', [], 'not found', 2); if (s == 'id -u') return _Proc(allow ? 0 : 1, utf8.encode(allow ? '0\n' : ''), allow ? '' : 'Permission denied'); final a = answers[s]; return a == null ? _Proc(1, const [], 'No such file or directory') : _Proc(a.$1, a.$2); } } void main() { late TestExplorerDevice device; setUp(() => device = TestExplorerDevice()); late Directory tmp; late _Su su; final wasStart = SuRoot.start; final wasRoot = ShizukuRemote.rootBackend; final wasTemp = ShizukuRemote.tempDir; final wasSupported = rootSupported; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_root_'); su = _Su(); SuRoot.start = su.start; SuRoot.resetForTest(); ShizukuRemote.rootBackend = SuRoot(); ShizukuRemote.tempDir = () async => tmp.path; rootSupported = () => true; ExplorerPage.rootWarnedThisRun = false; }); tearDown(() { SuRoot.start = wasStart; ShizukuRemote.rootBackend = wasRoot; ShizukuRemote.tempDir = wasTemp; rootSupported = wasSupported; tmp.deleteSync(recursive: true); }); test('셸 경로: 작은따옴표로 감쌈 · 상대 경로 · 줄바꿈 · \\0 은 거절 · 고정 명령 셋', () { expect(shellPath("/data/it's"), "'/data/it'\\''s'"); expect(shellPath(r'/a/$(rm -rf x)'), r"'/a/$(rm -rf x)'"); for (final bad in ['data', '/a\nb', '/a\u0000b', '']) { expect(() => shellPath(bad), throwsArgumentError, reason: bad); } expect(shellStatScript('/data'), "stat -c '%F|%s|%Y|%n' -- '/data'"); expect(shellCatScript('/x'), "cat -- '/x'"); expect(shellListScript('/'), startsWith("cd '/' && for f in")); }); test('su: 있는지 (묻지 않음) → 허락 (id -u = 0) · 목록 · 정보 · 내용 (끝 코드가 0 이 아니면 반쪽 파일을 지우고 오류) · 거절 · su 없음', () async { final r = SuRoot(); expect(await r.status(), (running: true, granted: false)); expect(su.calls, ['sh command -v su'], reason: '있는지만 볼 때는 허락 창을 띄우지 않음'); expect(await r.request(), isTrue); expect((await r.status()).granted, isTrue, reason: '이번 실행에서는 기억'); su.answers[shellListScript('/data')] = (0, utf8.encode('directory|4096|1700000000|app\n')); expect(await r.list('/data'), 'directory|4096|1700000000|app\n'); expect(await r.stat('/none'), '', reason: '없으면 빈 글'); su.answers[shellCatScript('/data/x.db')] = (0, [1, 2, 3]); final dest = p.join(tmp.path, 'x'); expect(await r.copyTo('/data/x.db', dest), 3); expect(File(dest).readAsBytesSync(), [1, 2, 3]); su.answers[shellCatScript('/data/bad')] = (1, [9]); await expectLater(r.copyTo('/data/bad', p.join(tmp.path, 'bad')), throwsA(isA())); expect(File(p.join(tmp.path, 'bad')).existsSync(), isFalse); // 거절 SuRoot.resetForTest(); su.allow = false; expect(await r.request(), isFalse); // su 없음 su.hasSu = false; expect((await r.status()).running, isFalse); expect(await r.request(), isFalse); }); test('경로: shz://root/… 는 루트 클라이언트 (읽기 전용) · 이름', () async { const path = '${shizukuScheme}root/data'; expect(isRootPath(path) && isShizuku(path), isTrue); expect(isRootPath('${shizukuScheme}sys/data'), isFalse); final d = DavPath.parse(path); expect(d.client, same(ShizukuRemote.root)); expect(d.serverLabel, '루트 (읽기 전용)'); await expectLater(d.client.delete('/data'), throwsA(isA())); expect(rootHome, 'shz://root/'); }); AppController ctl({bool on = true}) { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rootEnabled = on ..explorerPaths = [tmp.path, tmp.path] ..explorerLayout = 'single'; return c; } Future wait(WidgetTester t, bool Function() ok) async { for (var i = 0; i < 200 && !ok(); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(const Duration(milliseconds: 20)); } for (var i = 0; i < 8; i++) { await t.pump(const Duration(milliseconds: 50)); } } testWidgets('탐색기: 켰을 때만 [루트 (읽기 전용)] 탭 · 처음 열 때 경고 (개인 자료) · su 허락 · "/" 를 엶 · 주황 띠 · 지우기는 막힘', (t) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); device.volumes = () => [('/storage/emulated/0', 'T:')]; su.answers[shellListScript('/')] = (0, utf8.encode('directory|0|1700000000|data\ndirectory|0|1700000000|system\n')); await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl(on: false))))); await wait(t, () => false); expect(find.text('루트 (읽기 전용)'), findsNothing); await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl())))); await wait(t, () => find.text('루트 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('루트 (읽기 전용)').first); await wait(t, () => find.text('알고 봅니다').evaluate().isNotEmpty); expect(find.textContaining('다른 앱의 개인 자료'), findsOneWidget); await t.tap(find.text('알고 봅니다')); await wait(t, () => find.text('system').evaluate().isNotEmpty); expect(su.calls, contains('su id -u'), reason: 'su 허락'); expect(su.calls.where((x) => x.contains('/data')), isEmpty, reason: '/data 는 고르기 전에 펼치지 않음'); expect(find.text('루트 (su) - 읽기 전용'), findsWidgets); await t.tap(find.text('system').last); await wait(t, () => false); await t.tap(find.text('삭제').first); await wait(t, () => find.textContaining('루트 폴더는 읽기 전용입니다').evaluate().isNotEmpty); expect(find.textContaining('루트 폴더는 읽기 전용입니다'), findsOneWidget); await t.pumpWidget(const SizedBox()); await t.pump(const Duration(seconds: 1)); }); testWidgets('탐색기: su 가 없거나 거절하면 알리고 들어가지 않음', (t) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); device.volumes = () => [('/storage/emulated/0', 'T:')]; ExplorerPage.rootWarnedThisRun = true; su.hasSu = false; await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl())))); await wait(t, () => find.text('루트 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('루트 (읽기 전용)').first); await wait(t, () => find.textContaining('su 가 없습니다').evaluate().isNotEmpty); expect(find.textContaining('루팅한 기기에서만'), findsOneWidget); su ..hasSu = true ..allow = false; await t.tap(find.text('루트 (읽기 전용)').first); await wait(t, () => find.textContaining('루트 허락이 없어').evaluate().isNotEmpty); expect(find.textContaining('루트 허락이 없어'), findsOneWidget); expect(su.calls.where((x) => x.startsWith('su cd ')), isEmpty, reason: '들어가지 않음'); await t.pumpWidget(const SizedBox()); await t.pump(const Duration(seconds: 1)); }); testWidgets('설정: 루트 켤 때 경고 (개인 자료 · 체크해야 [켜기]) · 끄기는 바로', (t) async { t.view.physicalSize = const Size(1200, 4000); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); final c = ctl(on: false); await t.pumpWidget(MaterialApp(home: SettingsPage(c: c))); await t.pump(); final tile = find.text('루트 폴더 보기 (su · 읽기 전용)'); await t.scrollUntilVisible(tile, 300, scrollable: find.byType(Scrollable).first); final sw = find.descendant(of: find.ancestor(of: tile, matching: find.byType(ListTile)).first, matching: find.byType(Switch)); Future settle() async { for (var i = 0; i < 10; i++) { await t.pump(const Duration(milliseconds: 50)); } } await t.tap(sw.first); await settle(); expect(find.text('루트 폴더 보기를 켤까요?'), findsOneWidget); expect(find.textContaining('다른 앱의 개인 자료'), findsOneWidget); expect(t.widget(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull); await t.tap(find.text('위 내용을 알고 켭니다')); await t.pump(); await t.tap(find.widgetWithText(FilledButton, '켜기')); await settle(); expect(c.settings.rootEnabled, isTrue); await t.tap(sw.first); await settle(); expect(c.settings.rootEnabled, isFalse); }); }