diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 33bd0e1..a6d19d8 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -14,6 +14,7 @@ import '../services/subtitle_provider.dart'; import '../services/media_tool.dart'; import '../core/shizuku.dart' show PrivilegedFs; import '../services/platform_services.dart'; +import 'app_session.dart'; import '../services/system_usage.dart'; import 'settings.dart'; import 'cleanup.dart' show removeUpdateLeftovers; @@ -60,6 +61,9 @@ class AppController extends ChangeNotifier { // ───────── 모듈 ───────── late final _logBook = AppLog(now: services.clock); + + /// 앱을 켜 둔 동안만 기억하는 화면 상태 (설계 M5 - 예전 화면 static) + final session = AppSession(); late final _jobs = JobQueue(onChanged: notifyListeners, log: _log); late final _files = SubtitleFiles(services.storage, _tool); late final _probe = VideoProbe(storage: services.storage, tool: _tool, files: _files, log: _log, fileAccess: () => fileAccess); diff --git a/lib/app/app_session.dart b/lib/app/app_session.dart new file mode 100644 index 0000000..385070e --- /dev/null +++ b/lib/app/app_session.dart @@ -0,0 +1,16 @@ +/// 약속 (설계 M5 AppSession): 앱을 켜 둔 동안만 기억하는 화면 상태 - 저장하지 않고, 앱 (AppController) 하나에 하나. +/// 예전에는 화면 클래스의 static 이라 앱 전체가 하나를 함께 썼고, 시험은 그것을 손으로 되돌려야 했다 (새 AppController 면 새로 시작). +/// 입력 · 출력: 값을 읽고 쓰기만 한다. 오류 없음 +library; + +class AppSession { + /// 시스템 폴더 (Shizuku) · 루트 경고를 이번 실행에서 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번) + bool shizukuWarned = false, rootWarned = false; + + /// 브라우저: 마지막으로 본 주소 (다시 열 때 그 주소로) + String? browserLastUrl; + + /// 브라우저 오른쪽 작업 현황 (화면 분할) 의 열림 (null = 아직 정하지 않음 - 작업 중이면 열림) · 너비 + bool? browserWorkOpen; + double browserWorkWidth = 360; +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index edd5c31..d589b9e 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -59,9 +59,6 @@ class BrowserPage extends StatefulWidget { /// 열려 있는 브라우저 화면 수 static int _open = 0; - /// 마지막으로 보던 주소: MKV 화면으로 갔다가 브라우저를 다시 열면 홈이 아니라 이 주소로 - static String? _lastUrl; - /// 마지막으로 붙은 웹뷰 (통합 테스트에서 페이지 내용을 읽는다) @visibleForTesting static WebNav? debugNav; @@ -89,7 +86,7 @@ class _BrowserPageState extends State with RouteAware { /// 207: Chrome 엔진 [다시 시도] 때마다 새로 int _cefTry = 0; - late String _url = widget.initialUrl ?? BrowserPage._lastUrl ?? widget.c.settings.browser.homeUrl; + late String _url = widget.initialUrl ?? widget.c.session.browserLastUrl ?? widget.c.settings.browser.homeUrl; String _title = ''; double _progress = 1; @@ -132,17 +129,15 @@ class _BrowserPageState extends State with RouteAware { bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false; WebNav? _nav; - /// 오른쪽 작업 현황 (화면 분할). 앱을 켜 둔 동안은 열림 여부 · 너비를 기억. - static bool? _workOpen; - static double _workWidth = 360; - late bool _work = _workOpen ?? widget.c.busy; - void _toggleWork() => setState(() => _workOpen = _work = !_work); + /// 오른쪽 작업 현황 (화면 분할). 앱을 켜 둔 동안은 열림 여부 · 너비를 기억 (c.session - 설계 M5) + late bool _work = widget.c.session.browserWorkOpen ?? widget.c.busy; + void _toggleWork() => setState(() => widget.c.session.browserWorkOpen = _work = !_work); late final _address = TextEditingController(text: _url); final _addressFocus = FocusNode(); late final BrowserHost _host = BrowserHost( onUrl: (u) { if (!mounted || u.isEmpty) return; - BrowserPage._lastUrl = u; + widget.c.session.browserLastUrl = u; if (u != _url) _tr.resetFails(); // 다른 페이지: 번역을 다시 시도 setState(() => _url = u); if (!_addressFocus.hasFocus) _address.text = u; @@ -431,10 +426,10 @@ class _BrowserPageState extends State with RouteAware { if (_work) ...[ SplitHandle(onDrag: (dx) => setState(() { final max = MediaQuery.sizeOf(context).width - 400; - _workWidth = (_workWidth - dx).clamp(260.0, max < 260 ? 260.0 : max); + widget.c.session.browserWorkWidth = (widget.c.session.browserWorkWidth - dx).clamp(260.0, max < 260 ? 260.0 : max); })), SizedBox( - width: _workWidth, + width: widget.c.session.browserWorkWidth, child: WorkPanel( c: widget.c, downloads: widget.downloads, @@ -480,4 +475,3 @@ class _BrowserPageState extends State with RouteAware { ); } - diff --git a/lib/ui/explorer/access_flow.dart b/lib/ui/explorer/access_flow.dart index a3965e8..104529e 100644 --- a/lib/ui/explorer/access_flow.dart +++ b/lib/ui/explorer/access_flow.dart @@ -22,8 +22,7 @@ class ExplorerAccess extends ChangeNotifier { /// 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) final Future Function(String path) probe; - /// 이번 실행에서 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번) - static bool shizukuWarned = false, rootWarned = false; + // 이번 실행에서 경고를 보였는지는 ctl.c.session (설계 M5 AppSession - 예전 static) @override void dispose() { @@ -84,7 +83,7 @@ class ExplorerAccess extends ChangeNotifier { /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) Future openRoot(BuildContext context, ExplorerPane pane) async { - if (!rootWarned) { + if (!ctl.c.session.rootWarned) { // 공용 확인 창: 첫 초점 [취소] (Enter 로 바로 열리지 않음) final ok = await confirmDialog( context, @@ -96,7 +95,7 @@ class ExplorerAccess extends ChangeNotifier { cancelFirst: true, ); if (!ok || !context.mounted) return; - rootWarned = true; + ctl.c.session.rootWarned = true; } try { final st = await PrivilegedFs.current.root.status(); @@ -121,7 +120,7 @@ class ExplorerAccess extends ChangeNotifier { /// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로 Future openShizuku(BuildContext context, ExplorerPane pane) async { - if (!shizukuWarned) { + if (!ctl.c.session.shizukuWarned) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -137,7 +136,7 @@ class ExplorerAccess extends ChangeNotifier { ), ); if (ok != true || !context.mounted) return; - shizukuWarned = true; + ctl.c.session.shizukuWarned = true; } try { final st = await PrivilegedFs.current.shizuku.status(); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5553fd5..1883ca0 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -55,7 +55,6 @@ export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; /// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 - /// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다 - class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; @@ -67,12 +66,6 @@ class ExplorerPage extends StatefulWidget { const ExplorerPage( {super.key, required this.c, this.rsync = false, this.clock = DateTime.now, this.device = const RealExplorerDevice()}); - /// 이번 실행에서 시스템 폴더 · 루트 경고를 보였는지 ([ExplorerAccess]) - static bool get shizukuWarnedThisRun => ExplorerAccess.shizukuWarned; - static set shizukuWarnedThisRun(bool v) => ExplorerAccess.shizukuWarned = v; - static bool get rootWarnedThisRun => ExplorerAccess.rootWarned; - static set rootWarnedThisRun(bool v) => ExplorerAccess.rootWarned = v; - static const routeName = 'explorer'; static const rsyncRouteName = 'rsync'; static int _open = 0, _openRsync = 0; diff --git a/test/root_test.dart b/test/root_test.dart index 7e9e613..d9694cd 100644 --- a/test/root_test.dart +++ b/test/root_test.dart @@ -64,7 +64,6 @@ void main() { SuRoot.forgetGrant(); // 설계 M3: 실제 SuRoot 에 가짜 su 프로세스만 넘기고, PrivilegedFs 하나로 건다 (Shizuku 쪽은 그대로) PrivilegedFs.use(PrivilegedFs(shizuku: was.shizuku, root: SuRoot(start: su.start), tempDir: () async => tmp.path)); - ExplorerPage.rootWarnedThisRun = false; }); tearDown(() { PrivilegedFs.use(was); @@ -174,9 +173,10 @@ void main() { 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())))); + // 경고는 이번 실행에서 이미 보였음 (설계 M5 - 앱 세션 c.session) + final c = ctl()..session.rootWarned = true; + await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: c)))); await wait(t, () => find.text('루트 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('루트 (읽기 전용)').first); await wait(t, () => find.textContaining('su 가 없습니다').evaluate().isNotEmpty); diff --git a/test/shizuku_test.dart b/test/shizuku_test.dart index d4ea200..4a647cf 100644 --- a/test/shizuku_test.dart +++ b/test/shizuku_test.dart @@ -58,7 +58,6 @@ void main() { fake = _Fake(); // 설계 M3: 가짜 Shizuku 뒤쪽 · 시험 임시 폴더를 PrivilegedFs 하나로 건다 (루트 쪽은 그대로) PrivilegedFs.use(PrivilegedFs(shizuku: fake, root: was.root, tempDir: () async => tmp.path)); - ExplorerPage.shizukuWarnedThisRun = false; }); tearDown(() { PrivilegedFs.use(was); @@ -145,8 +144,9 @@ void main() { 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())))); + // 켬 (같은 앱 - 아래 "다시 열기" 도 이 c: 경고 기억은 앱 세션 c.session, 설계 M5) + final c = ctl(); + await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: c)))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.text('알고 봅니다').evaluate().isNotEmpty); @@ -174,9 +174,9 @@ void main() { 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 t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: c)))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.text('obb').evaluate().isNotEmpty); @@ -190,9 +190,10 @@ void main() { 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())))); + // 경고는 이번 실행에서 이미 보였음 (설계 M5 - 앱 세션 c.session) + final c = ctl()..session.shizukuWarned = true; + await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: c)))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.textContaining('Shizuku 가 켜져 있지 않습니다').evaluate().isNotEmpty);