import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import '../../core/shizuku.dart'; import '../../l10n/tr.dart'; import '../../platform/android/android_storage.dart'; import '../theme.dart'; import 'explorer_controller.dart'; import 'explorer_pane.dart'; /// 약속 (E5d-6): 저장 장치에 닿는 일 - Android "모든 파일에 대한 접근" 권한 ([allFiles] · 듀얼 앱, 앱으로 돌아오면 다시 확인), /// 시스템 폴더 (Shizuku) · 루트 (su) 탭 (이번 실행에서 처음이면 경고 → 켜져 있고 허락받았을 때만 연다, 읽기 전용), /// 연결 안 된 드라이브 다시 연결 ([reconnect] - 되면 연다, 아니면 까닭을 알림). 바뀌면 알림 class ExplorerAccess extends ChangeNotifier { ExplorerAccess(this.ctl, {required this.probe}); final ExplorerController ctl; /// 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) final Future Function(String path) probe; /// 이번 실행에서 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번) static bool shizukuWarned = false, rootWarned = false; @override void dispose() { if (allFiles != null) _life.dispose(); super.dispose(); } Future probeDrive(String path) => ctl.drives.check(path, probe); /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 Future reconnect(ExplorerPane pane, String path) async { await probeDrive(path); if (!ctl.host.alive) return; if (ctl.drives.ok[path] == true) { pane.cache.remove(path); await ctl.goTo(pane, path); } else { ctl.host.snack(ctl.drives.removable.contains(path) ? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path]) : trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); } } // ───────── 권한 (Android) ───────── /// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름 bool? allFiles; /// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님 bool dualApp = false; late final AppLifecycleListener _life = AppLifecycleListener(onResume: () { if (Platform.isAndroid && allFiles == false) unawaited(checkAccess(reload: true)); }); Future checkAccess({bool reload = false}) async { _life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인 final ok = await AndroidAccess.hasAllFiles(); var dual = false; try { final root = await AndroidAccess.storageRoot(); dual = root.contains('/emulated/') && !root.endsWith('/emulated/0'); } catch (_) {} if (!ctl.host.alive) return; final changed = ok != allFiles; allFiles = ok; dualApp = dual; notifyListeners(); if (reload && changed && ok) { for (final pane in ctl.panes) { pane.cache.clear(); for (final d in pane.expanded.toList()) { await ctl.load(pane, d, force: true); } } } } /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) Future openRoot(BuildContext context, ExplorerPane pane) async { if (!rootWarned) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: Icon(Icons.security, color: JjColors.warning), title: Text(tr('루트 폴더 보기 (읽기 전용)')), content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 ' '보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), ], ), ); if (ok != true || !context.mounted) return; rootWarned = true; } try { final st = await ShizukuRemote.rootBackend.status(); if (!st.running) { ctl.host.snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다')); return; } if (!st.granted && !await ShizukuRemote.rootBackend.request()) { ctl.host.snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)')); return; } } catch (e) { ctl.host.snack(trf('루트에 연결하지 못했습니다: {0}', [e])); return; } if (!context.mounted) return; ctl ..active = ctl.panes.indexOf(pane) ..update(); await ctl.goTo(pane, rootHome); } /// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로 Future openShizuku(BuildContext context, ExplorerPane pane) async { if (!shizukuWarned) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning), title: Text(tr('시스템 폴더 보기 (읽기 전용)')), content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 ' '있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), ], ), ); if (ok != true || !context.mounted) return; shizukuWarned = true; } try { final st = await ShizukuRemote.backend.status(); if (!st.running) { ctl.host.snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요')); return; } if (!st.granted && !await ShizukuRemote.backend.request()) { ctl.host.snack(tr('Shizuku 허락이 없어 볼 수 없습니다')); return; } } catch (e) { ctl.host.snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e])); return; } if (!context.mounted) return; ctl ..active = ctl.panes.indexOf(pane) ..update(); await ctl.goTo(pane, shizukuHome(ctl.local.first.$1)); } }