247 lines
12 KiB
Dart
247 lines
12 KiB
Dart
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 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21)
|
|
class _Fake implements ShizukuBackend {
|
|
bool running = true, granted = false, allow = true;
|
|
final dirs = <String, String>{};
|
|
final files = <String, String>{};
|
|
final calls = <String>[];
|
|
@override
|
|
Future<({bool running, bool granted})> status() async => (running: running, granted: granted);
|
|
@override
|
|
Future<bool> request() async {
|
|
calls.add('request');
|
|
granted = allow;
|
|
return allow;
|
|
}
|
|
|
|
@override
|
|
Future<String> list(String path) async {
|
|
calls.add('list:$path');
|
|
final d = dirs[path];
|
|
if (d == null) throw Exception('Permission denied');
|
|
return d;
|
|
}
|
|
|
|
@override
|
|
Future<String> stat(String path) async => files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.basename(path)}' : '';
|
|
@override
|
|
Future<int> copyTo(String path, String dest) async {
|
|
calls.add('copy:$path');
|
|
File(dest).writeAsStringSync(files[path]!);
|
|
return files[path]!.length;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
late TestExplorerDevice device;
|
|
setUp(() => device = TestExplorerDevice());
|
|
late Directory tmp;
|
|
late _Fake fake;
|
|
final wasBackend = ShizukuRemote.backend;
|
|
final wasTemp = ShizukuRemote.tempDir;
|
|
final wasSupported = shizukuSupported;
|
|
setUp(() {
|
|
tmp = Directory.systemTemp.createTempSync('jj_shz_');
|
|
fake = _Fake();
|
|
ShizukuRemote.backend = fake;
|
|
ShizukuRemote.tempDir = () async => tmp.path;
|
|
shizukuSupported = () => true;
|
|
ExplorerPage.shizukuWarnedThisRun = false;
|
|
});
|
|
tearDown(() {
|
|
ShizukuRemote.backend = wasBackend;
|
|
ShizukuRemote.tempDir = wasTemp;
|
|
shizukuSupported = wasSupported;
|
|
tmp.deleteSync(recursive: true);
|
|
});
|
|
|
|
test('stat 한 줄 읽기: 폴더 · 파일 · 이름에 | · 틀린 줄', () {
|
|
expect(parseShizukuStat('directory|3452|1700000000|com.app'), (true, 3452, DateTime.fromMillisecondsSinceEpoch(1700000000000), 'com.app'));
|
|
expect(parseShizukuStat('regular file|12|1700000001|a|b.txt')?.$4, 'a|b.txt');
|
|
expect(parseShizukuStat('regular empty file|0|1700000001|e'), isNotNull);
|
|
expect(parseShizukuStat('stat: x: Permission denied'), isNull);
|
|
expect(parseShizukuStat(''), isNull);
|
|
});
|
|
|
|
test('경로 · 클라이언트: shz://sys/<절대 경로> → 읽기 전용 클라이언트, 목록 · 정보 · 내용 읽기 (임시는 지움) · 쓰기 · 지우기 · 옮기기 · 새 폴더는 막힘', () async {
|
|
const dir = '/storage/emulated/0/Android/data';
|
|
fake.dirs[dir] = 'directory|4096|1700000000|com.game\nregular file|5|1700000000|note.txt\nbad line\n';
|
|
fake.files['$dir/note.txt'] = 'hello';
|
|
final path = '$shizukuScheme$shizukuServer$dir';
|
|
expect(isDav(path) && isShizuku(path) && noStreamUrl(path), isTrue);
|
|
final d = DavPath.parse(path);
|
|
expect((d.rel, d.mark, d.serverKnown), (dir, '🛡', true));
|
|
final c = d.client;
|
|
expect(c, isA<ShizukuRemote>());
|
|
final items = await c.list(dir);
|
|
expect(items.map((x) => (x.rel, x.isDir, x.size)), [('$dir/com.game', true, 0), ('$dir/note.txt', false, 5)]);
|
|
expect((await c.stat('$dir/note.txt'))?.size, 5);
|
|
expect(await c.stat('$dir/none'), isNull);
|
|
final bytes = (await (await c.openRead('$dir/note.txt')).toList()).expand((x) => x).toList();
|
|
expect(String.fromCharCodes(bytes), 'hello');
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
|
expect(tmp.listSync().where((e) => p.basename(e.path).startsWith('jj_shz_')), isEmpty, reason: '받아 둔 임시는 지움');
|
|
for (final f in <Future<void> Function()>[
|
|
() => c.write('$dir/x', const Stream.empty()),
|
|
() => c.delete('$dir/note.txt'),
|
|
() => c.move('$dir/note.txt', '$dir/y'),
|
|
() => c.mkdirs('$dir/new'),
|
|
]) {
|
|
await expectLater(f(), throwsA(isA<ShizukuReadOnlyException>()));
|
|
}
|
|
// 탐색기가 쓰는 vfs 길로도
|
|
final listed = await vList(path);
|
|
expect(listed.map((e) => p.posix.basename(e.path)), ['com.game', 'note.txt']);
|
|
expect(shizukuHome('/storage/emulated/0'), 'shz://sys/storage/emulated/0/Android');
|
|
});
|
|
|
|
AppController ctl({bool on = true}) {
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings
|
|
..tools.shizukuEnabled = 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('탐색기: 켰을 때만 [시스템 (읽기 전용)] 탭 · 처음 열 때 경고 · 허락 창 · Android 폴더 · 주황 띠 · 지우기는 막힘 · 다시 열 땐 경고 없음', (t) async {
|
|
t.view.physicalSize = const Size(1400, 900);
|
|
t.view.devicePixelRatio = 1;
|
|
addTearDown(t.view.reset);
|
|
// 탭이 여는 곳은 첫 저장 장치의 Android (기기 경로) - 그 저장 장치는 이 PC 에서 열리지 않아도 됨
|
|
device.volumes = () => [('/storage/emulated/0', 'T:')];
|
|
const home = '/storage/emulated/0/Android';
|
|
// 트리는 위 폴더부터 읽는다 (기기에서는 adb 셸이 / · /storage 를 읽을 수 있음)
|
|
fake.dirs['/'] = 'directory|0|1700000000|storage\n';
|
|
fake.dirs['/storage'] = 'directory|0|1700000000|emulated\n';
|
|
fake.dirs['/storage/emulated'] = 'directory|0|1700000000|0\n';
|
|
fake.dirs['/storage/emulated/0'] = 'directory|0|1700000000|Android\n';
|
|
fake.dirs[home] = 'directory|4096|1700000000|data\ndirectory|4096|1700000000|obb\n';
|
|
fake.dirs['$home/data'] = 'directory|4096|1700000000|com.game\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('obb').evaluate().isNotEmpty);
|
|
expect(fake.calls, contains('request'), reason: '허락을 받지 않았으면 Shizuku 허락 창');
|
|
expect(find.text('data'), findsWidgets);
|
|
expect(find.text('시스템 폴더 (Shizuku) - 읽기 전용'), findsWidgets);
|
|
// 지우기는 막힘 (꺼내기만)
|
|
await t.tap(find.text('obb').last);
|
|
await wait(t, () => false);
|
|
await t.tap(find.text('삭제').first);
|
|
await wait(t, () => find.textContaining('읽기 전용입니다').evaluate().isNotEmpty);
|
|
expect(find.textContaining('시스템 폴더 (Shizuku) 는 읽기 전용입니다'), findsOneWidget);
|
|
expect(find.text('obb'), findsWidgets, reason: '지워지지 않음');
|
|
await t.tap(find.text('이름 변경').first);
|
|
await wait(t, () => false);
|
|
expect(find.byType(TextField), findsNothing, reason: '이름 바꾸기 창도 열리지 않음');
|
|
// 28 지적 · 관리자 지시 (10/21): 이동은 시작 전에 막음 (예전에는 복사는 되고 원본 지우기에서 오류)
|
|
ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars();
|
|
await wait(t, () => false);
|
|
await t.tap(find.text('이동').first);
|
|
await wait(t, () => find.textContaining('옮길 수 없습니다').evaluate().isNotEmpty);
|
|
expect(find.text('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'), findsOneWidget);
|
|
expect(find.textContaining('담았습니다'), findsNothing, reason: '이동으로 담지도 않음');
|
|
expect(fake.calls.where((x) => x.startsWith('copy:')), isEmpty);
|
|
// 다시 열기: 경고 없이 (이번 실행에서 이미 봄)
|
|
await t.pumpWidget(const SizedBox());
|
|
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('obb').evaluate().isNotEmpty);
|
|
expect(find.text('알고 봅니다'), findsNothing);
|
|
await t.pumpWidget(const SizedBox());
|
|
await t.pump(const Duration(seconds: 1));
|
|
});
|
|
|
|
testWidgets('탐색기: Shizuku 가 꺼져 있거나 허락하지 않으면 알리고 들어가지 않음', (t) async {
|
|
t.view.physicalSize = const Size(1400, 900);
|
|
t.view.devicePixelRatio = 1;
|
|
addTearDown(t.view.reset);
|
|
device.volumes = () => [(tmp.path, 'T:')];
|
|
ExplorerPage.shizukuWarnedThisRun = true;
|
|
fake.running = 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('Shizuku 가 켜져 있지 않습니다').evaluate().isNotEmpty);
|
|
expect(find.textContaining('Shizuku 가 켜져 있지 않습니다'), findsOneWidget);
|
|
fake
|
|
..running = true
|
|
..allow = false;
|
|
await t.tap(find.text('시스템 (읽기 전용)').first);
|
|
await wait(t, () => find.text('Shizuku 허락이 없어 볼 수 없습니다').evaluate().isNotEmpty);
|
|
expect(find.text('Shizuku 허락이 없어 볼 수 없습니다'), findsOneWidget);
|
|
expect(fake.calls.where((x) => x.startsWith('list:')), 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('시스템 폴더 보기 (Shizuku · 읽기 전용)');
|
|
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(t.widget<FilledButton>(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull, reason: '체크해야 켜짐');
|
|
await t.tap(find.text('취소'));
|
|
await settle();
|
|
expect(c.settings.tools.shizukuEnabled, isFalse);
|
|
await t.tap(sw.first);
|
|
await settle();
|
|
await t.tap(find.text('위 내용을 알고 켭니다'));
|
|
await t.pump();
|
|
await t.tap(find.widgetWithText(FilledButton, '켜기'));
|
|
await settle();
|
|
expect(c.settings.tools.shizukuEnabled, isTrue);
|
|
await t.tap(sw.first);
|
|
await settle();
|
|
expect(c.settings.tools.shizukuEnabled, isFalse, reason: '끄기는 묻지 않음');
|
|
});
|
|
}
|