diff --git a/integration_test/support/shot_services.dart b/integration_test/support/shot_services.dart index 00f0656..1a53bf2 100644 --- a/integration_test/support/shot_services.dart +++ b/integration_test/support/shot_services.dart @@ -30,6 +30,7 @@ PlatformServices shotServices() { diskSpace: (_) async => (107374182400, 536870912000), clock: r.clock, vault: r.vault, + privileged: r.privileged, updater: r.updater, usage: r.usage == null ? null : const FixedUsage(), models: r.models, diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 5b4bc51..33bd0e1 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -12,6 +12,7 @@ import '../core/srt.dart'; import '../core/subtitle_search.dart'; import '../services/subtitle_provider.dart'; import '../services/media_tool.dart'; +import '../core/shizuku.dart' show PrivilegedFs; import '../services/platform_services.dart'; import '../services/system_usage.dart'; import 'settings.dart'; @@ -42,7 +43,10 @@ class AppController extends ChangeNotifier { final SettingsStore? settingsStore; AppSettings settings = AppSettings(); - AppController(this.services, {this.settingsStore}); + AppController(this.services, {this.settingsStore}) { + // 설계 M3: 시스템 폴더 · 루트 읽기는 앱 조립이 넘긴 것으로 (넘기지 않으면 그대로) + if (services.privileged != null) PrivilegedFs.use(services.privileged!); + } /// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용 Future Function()? confirmQuit; diff --git a/lib/core/shizuku.dart b/lib/core/shizuku.dart index d7bb564..242b81a 100644 --- a/lib/core/shizuku.dart +++ b/lib/core/shizuku.dart @@ -17,9 +17,6 @@ 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'; @@ -29,8 +26,7 @@ const rootServer = 'root'; bool isRootPath(String path) => path.startsWith('$shizukuScheme$rootServer/') || path == '$shizukuScheme$rootServer'; -/// 루트 보기를 쓰는 기기 (Android - 시험에서 바꿈) -bool Function() rootSupported = () => Platform.isAndroid; +// 시스템 폴더 · 루트 보기를 쓰는 기기인지는 PlatformInfo.isAndroid (c.services.platform - 예전 전역 함수 shizukuSupported · rootSupported) /// 루트 탭이 처음 여는 곳 (관리자 3a 결정 10/21: "/" - 다른 앱의 개인 자료가 있는 /data 를 고르지 않았는데 펼치지 않게) const rootHome = '$shizukuScheme$rootServer/'; @@ -49,8 +45,9 @@ String shellCatScript(String path) => 'cat -- ${shellPath(path)}'; /// su 로 (루팅한 기기). 이번 실행에서 허락을 받았는지만 기억한다 (허락 · 거절은 Magisk 같은 su 앱이 기억) class SuRoot implements ShizukuBackend { - /// 프로세스 시작 (시험에서 바꿈) - static Future Function(String exe, List args) start = (exe, args) => Process.start(exe, args); + /// 프로세스 시작 (처음 값 = 실제 Process.start - 시험은 가짜 su 를 넘긴다) + final Future Function(String exe, List args) start; + SuRoot({Future Function(String exe, List args)? start}) : start = start ?? Process.start; static bool _granted = false; @@ -187,23 +184,34 @@ class ShizukuReadOnlyException extends FileSystemException { return (parts[0].trim() == 'directory', size, DateTime.fromMillisecondsSinceEpoch(secs * 1000), name); } -/// [RemoteClient] 로 탐색기 · 복사 (꺼내기) · 보기가 그대로 쓰게. rel = 기기 절대 경로 +/// 약속 (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 Function() tempDir; + const PrivilegedFs({required this.shizuku, required this.root, required this.tempDir}); + + factory PrivilegedFs.device(Future 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 ShizukuBackend backend = AndroidShizuku(); - - /// 루트 (su) 쪽 - 시험에서 바꿈 - static ShizukuBackend rootBackend = SuRoot(); - - /// 내용을 잠깐 받아 둘 앱 전용 폴더 (시험에서 바꿈) - static Future 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; + ShizukuBackend get _b => isRoot ? PrivilegedFs.current.root : PrivilegedFs.current.shizuku; String _abs(String rel) => rel.isEmpty ? '/' : rel; @@ -230,7 +238,7 @@ class ShizukuRemote implements RemoteClient { /// 앱 전용 임시 폴더에 받아 두고 읽은 뒤 지운다 @override Future>> openRead(String rel) async { - final dir = Directory(p.join(await tempDir(), 'jj_shz_${DateTime.now().microsecondsSinceEpoch}')); + 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 { diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index a8c2c5e..3fde222 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -7,6 +7,7 @@ import '../platform/common/media_kit_player.dart'; import '../app/ai_upscale.dart' show AiUpscaler; import '../app/settings.dart'; import '../app/vault_location.dart'; +import '../core/shizuku.dart' show PrivilegedFs; import '../core/download_detect.dart'; import '../core/lan_scan.dart' show LanHost; import '../platform/common/nllb_translator.dart'; @@ -83,6 +84,10 @@ class PlatformServices { /// 손으로 조립하면 처음 값은 앱 임시 폴더 아래 ([VaultLocation.under] - 사용자 보관함 · 앱 데이터를 건드리지 않음) final VaultLocation vault; + /// 시스템 폴더 (Shizuku) · 루트 (su) 읽기 (설계 M3). 있으면 AppController 가 켤 때 PrivilegedFs.use 로 건다 - Android 조립만 넘기고, + /// 없으면 PrivilegedFs.current 를 그대로 둔다 (시험이 건 가짜를 덮지 않게) + final PrivilegedFs? privileged; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -108,6 +113,7 @@ class PlatformServices { AppShell? shell, PlatformInfo? platform, VaultLocation? vault, + this.privileged, }) : models = models ?? ModelStore(), vault = vault ?? VaultLocation.under(storage.tempDirectory), platform = platform ?? PlatformInfo.host, @@ -159,6 +165,7 @@ class PlatformServices { final storage = AndroidStorageService(); return PlatformServices( vault: VaultLocation.device, + privileged: PrivilegedFs.device(storage.tempDirectory), mediaTool: FfmpegKitMediaTool(), storage: storage, updater: AndroidUpdater(storage), diff --git a/lib/ui/explorer/access_flow.dart b/lib/ui/explorer/access_flow.dart index 8475ac3..a3965e8 100644 --- a/lib/ui/explorer/access_flow.dart +++ b/lib/ui/explorer/access_flow.dart @@ -99,12 +99,12 @@ class ExplorerAccess extends ChangeNotifier { rootWarned = true; } try { - final st = await ShizukuRemote.rootBackend.status(); + final st = await PrivilegedFs.current.root.status(); if (!st.running) { ctl.host.snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다')); return; } - if (!st.granted && !await ShizukuRemote.rootBackend.request()) { + if (!st.granted && !await PrivilegedFs.current.root.request()) { ctl.host.snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)')); return; } @@ -140,12 +140,12 @@ class ExplorerAccess extends ChangeNotifier { shizukuWarned = true; } try { - final st = await ShizukuRemote.backend.status(); + final st = await PrivilegedFs.current.shizuku.status(); if (!st.running) { ctl.host.snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요')); return; } - if (!st.granted && !await ShizukuRemote.backend.request()) { + if (!st.granted && !await PrivilegedFs.current.shizuku.request()) { ctl.host.snack(tr('Shizuku 허락이 없어 볼 수 없습니다')); return; } diff --git a/lib/ui/explorer/explorer_controller.dart b/lib/ui/explorer/explorer_controller.dart index 0a1dbad..31f0008 100644 --- a/lib/ui/explorer/explorer_controller.dart +++ b/lib/ui/explorer/explorer_controller.dart @@ -92,8 +92,8 @@ class ExplorerController extends ChangeNotifier { for (final s in c.settings.remote.smbServers) ('$smbScheme${s.id}/', s.label), // l: 평문 FTP 는 이름 앞에 ⚠ for (final s in c.settings.remote.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'), - if (c.settings.tools.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')), - if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), + if (c.settings.tools.shizukuEnabled && c.services.platform.isAndroid) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')), + if (c.settings.tools.rootEnabled && c.services.platform.isAndroid) (rootHome, tr('루트 (읽기 전용)')), ]; String volumeOf(String path) => vol.volumeOf(path, local); diff --git a/lib/ui/settings/files_section.dart b/lib/ui/settings/files_section.dart index ec4f073..4556f10 100644 --- a/lib/ui/settings/files_section.dart +++ b/lib/ui/settings/files_section.dart @@ -11,7 +11,6 @@ import '../smb_settings.dart'; import '../webdav_settings.dart'; import '../explorer_look.dart' show ExplorerStyle; import '../explorer_keys_page.dart'; -import '../../core/shizuku.dart' show rootSupported, shizukuSupported; import '../theme.dart'; import '../../l10n/tr.dart'; import '../setting_tile.dart'; @@ -90,7 +89,7 @@ class _FilesSettingsSectionState extends State { ), ), // X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고 - if (shizukuSupported()) + if (c.services.platform.isAndroid) SettingTile( title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')), subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')), @@ -110,7 +109,7 @@ class _FilesSettingsSectionState extends State { ), ), // X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀 - if (rootSupported()) + if (c.services.platform.isAndroid) SettingTile( title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')), subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')), diff --git a/test/privileged_fs_contract_test.dart b/test/privileged_fs_contract_test.dart new file mode 100644 index 0000000..d3f3d37 --- /dev/null +++ b/test/privileged_fs_contract_test.dart @@ -0,0 +1,35 @@ +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/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; + +class _Back implements ShizukuBackend { + _Back(this.name); + final String name; + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +/// 약속 (M3 PrivilegedFs): 앱 조립이 privileged 를 넘기면 AppController 가 켤 때 건다 · 넘기지 않으면 지금 것을 그대로 둔다 (시험의 가짜를 덮지 않음) · +/// ShizukuRemote 는 지금 건 뒤쪽 (시스템 · 루트) 을 쓴다 +void main() { + final was = PrivilegedFs.current; + tearDown(() => PrivilegedFs.use(was)); + + test('privileged 를 넘기면 켤 때 걸고, 안 넘기면 그대로', () { + final a = PrivilegedFs(shizuku: _Back('a'), root: _Back('ar'), tempDir: () async => 'A'); + PrivilegedFs.use(a); + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + expect(identical(PrivilegedFs.current, a), isTrue, reason: '안 넘기면 그대로'); + final b = PrivilegedFs(shizuku: _Back('b'), root: _Back('br'), tempDir: () async => 'B'); + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), privileged: b)); + expect(identical(PrivilegedFs.current, b), isTrue, reason: '넘기면 건다'); + }); + + test('device: Shizuku 는 AndroidShizuku, 루트는 SuRoot (실제 Process.start), 임시는 넘긴 폴더', () async { + final d = PrivilegedFs.device(() async => 'T'); + expect([d.shizuku is AndroidShizuku, d.root is SuRoot, await d.tempDir()], [true, true, 'T']); + }); +} diff --git a/test/root_test.dart b/test/root_test.dart index 294f3d4..7e9e613 100644 --- a/test/root_test.dart +++ b/test/root_test.dart @@ -14,6 +14,7 @@ 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'; +import 'package:jj_mkvmaker/services/platform_info.dart'; /// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용 - 관리자 결정 10/21: Shizuku 와 같은 틀, 처음 여는 곳 "/") class _Proc implements Process { @@ -56,25 +57,17 @@ void main() { setUp(() => device = TestExplorerDevice()); late Directory tmp; late _Su su; - final wasStart = SuRoot.start; - final wasRoot = ShizukuRemote.rootBackend; - final wasTemp = ShizukuRemote.tempDir; - final wasSupported = rootSupported; + final was = PrivilegedFs.current; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_root_'); su = _Su(); - SuRoot.start = su.start; SuRoot.forgetGrant(); - ShizukuRemote.rootBackend = SuRoot(); - ShizukuRemote.tempDir = () async => tmp.path; - rootSupported = () => true; + // 설계 M3: 실제 SuRoot 에 가짜 su 프로세스만 넘기고, PrivilegedFs 하나로 건다 (Shizuku 쪽은 그대로) + PrivilegedFs.use(PrivilegedFs(shizuku: was.shizuku, root: SuRoot(start: su.start), tempDir: () async => tmp.path)); ExplorerPage.rootWarnedThisRun = false; }); tearDown(() { - SuRoot.start = wasStart; - ShizukuRemote.rootBackend = wasRoot; - ShizukuRemote.tempDir = wasTemp; - rootSupported = wasSupported; + PrivilegedFs.use(was); tmp.deleteSync(recursive: true); }); @@ -90,7 +83,7 @@ void main() { }); test('su: 있는지 (묻지 않음) → 허락 (id -u = 0) · 목록 · 정보 · 내용 (끝 코드가 0 이 아니면 반쪽 파일을 지우고 오류) · 거절 · su 없음', () async { - final r = SuRoot(); + final r = SuRoot(start: su.start); // 가짜 su 프로세스 (설계 M3 - 예전 static start 대신 넘김) expect(await r.status(), (running: true, granted: false)); expect(su.calls, ['sh command -v su'], reason: '있는지만 볼 때는 허락 창을 띄우지 않음'); expect(await r.request(), isTrue); @@ -127,7 +120,9 @@ void main() { }); AppController ctl({bool on = true}) { - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 시스템 폴더 · 루트 탭은 Android 에서만 (설계 M3 - 예전 전역 함수 대신 PlatformInfo 주입) + final c = AppController( + PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android)); c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander c.settings ..tools.rootEnabled = on diff --git a/test/shizuku_test.dart b/test/shizuku_test.dart index f0ca03c..d4ea200 100644 --- a/test/shizuku_test.dart +++ b/test/shizuku_test.dart @@ -12,6 +12,7 @@ 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'; +import 'package:jj_mkvmaker/services/platform_info.dart'; /// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21) class _Fake implements ShizukuBackend { @@ -51,21 +52,16 @@ void main() { setUp(() => device = TestExplorerDevice()); late Directory tmp; late _Fake fake; - final wasBackend = ShizukuRemote.backend; - final wasTemp = ShizukuRemote.tempDir; - final wasSupported = shizukuSupported; + final was = PrivilegedFs.current; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_shz_'); fake = _Fake(); - ShizukuRemote.backend = fake; - ShizukuRemote.tempDir = () async => tmp.path; - shizukuSupported = () => true; + // 설계 M3: 가짜 Shizuku 뒤쪽 · 시험 임시 폴더를 PrivilegedFs 하나로 건다 (루트 쪽은 그대로) + PrivilegedFs.use(PrivilegedFs(shizuku: fake, root: was.root, tempDir: () async => tmp.path)); ExplorerPage.shizukuWarnedThisRun = false; }); tearDown(() { - ShizukuRemote.backend = wasBackend; - ShizukuRemote.tempDir = wasTemp; - shizukuSupported = wasSupported; + PrivilegedFs.use(was); tmp.deleteSync(recursive: true); }); @@ -110,7 +106,9 @@ void main() { }); AppController ctl({bool on = true}) { - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 시스템 폴더 · 루트 탭은 Android 에서만 (설계 M3 - 예전 전역 함수 대신 PlatformInfo 주입) + final c = AppController( + PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android)); c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander c.settings ..tools.shizukuEnabled = on diff --git a/test/vault_page_test.dart b/test/vault_page_test.dart index a7d63eb..edbf220 100644 --- a/test/vault_page_test.dart +++ b/test/vault_page_test.dart @@ -346,13 +346,9 @@ void main() { addTearDown(t.view.reset); final viewRoot = Directory(p.join(tmp.path, 'viewroot')); // where.viewRoot final remote = _Remote({'/r/remote.txt': 'from nas'}); - final wasBackend = ShizukuRemote.backend, wasTemp = ShizukuRemote.tempDir; - ShizukuRemote.backend = remote; - ShizukuRemote.tempDir = () async => tmp.path; - addTearDown(() { - ShizukuRemote.backend = wasBackend; - ShizukuRemote.tempDir = wasTemp; - }); + final was = PrivilegedFs.current; + PrivilegedFs.use(PrivilegedFs(shizuku: remote, root: was.root, tempDir: () async => tmp.path)); + addTearDown(() => PrivilegedFs.use(was)); final album = Directory(p.join(tmp.path, 'album')); File(p.join(album.path, 'a.jpg')) ..createSync(recursive: true)