230 lines
10 KiB
Dart
230 lines
10 KiB
Dart
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<int> out;
|
|
final String err;
|
|
_Proc(this.code, this.out, [this.err = '']);
|
|
@override
|
|
Stream<List<int>> get stdout => Stream.value(out);
|
|
@override
|
|
Stream<List<int>> get stderr => Stream.value(utf8.encode(err));
|
|
@override
|
|
IOSink get stdin => IOSink(StreamController<List<int>>()..stream.listen((_) {}));
|
|
@override
|
|
Future<int> 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 = <String>[];
|
|
final answers = <String, (int, List<int>)>{};
|
|
Future<Process> start(String exe, List<String> 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<FileSystemException>()));
|
|
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<ShizukuReadOnlyException>()));
|
|
expect(rootHome, 'shz://root/');
|
|
});
|
|
|
|
AppController ctl({bool on = true}) {
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings
|
|
..tools.rootEnabled = on
|
|
..explorer.paths = [tmp.path, tmp.path]
|
|
..explorer.layout = 'single';
|
|
return c;
|
|
}
|
|
|
|
Future<void> wait(WidgetTester t, bool Function() ok) async {
|
|
for (var i = 0; i < 200 && !ok(); i++) {
|
|
await t.runAsync(() => Future<void>.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<void> 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<FilledButton>(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull);
|
|
await t.tap(find.text('위 내용을 알고 켭니다'));
|
|
await t.pump();
|
|
await t.tap(find.widgetWithText(FilledButton, '켜기'));
|
|
await settle();
|
|
expect(c.settings.tools.rootEnabled, isTrue);
|
|
await t.tap(sw.first);
|
|
await settle();
|
|
expect(c.settings.tools.rootEnabled, isFalse);
|
|
});
|
|
}
|