WIP X-plore 비교 5-2 루트 폴더 보기 (su · 읽기 전용) (검토 대기)

관리자 지시 10/21: Shizuku 와 같은 틀 - 처음 끔 · 경고 두 번 (켤 때 · 앱을 켠 뒤 처음 열 때) · 주황 띠 · 고정 명령 셋 · 쓰기 길 없음.
- su 를 Dart 에서 바로 (Kotlin 없음), 경로는 shz://root/… - 시스템 폴더와 같은 읽기 전용 막기 (탐색기 · 클라이언트 둘 다)
- su 가 없거나 거절하면 안내만 (들어가지 않음). 있는지 볼 때는 허락 창을 띄우지 않고, 탭을 누를 때 su 앱의 허락 창
- 처음 여는 곳 "/" (관리자 3a 결정: 다른 앱의 개인 자료가 있는 /data 를 고르기 전에 펼치지 않음), 경고 창에 "다른 앱의 개인 자료도 보일 수 있습니다"
- 셸 경로는 절대 경로만 · 줄바꿈 · \0 거절 · 작은따옴표로 감쌈
- 설정의 켜기 경고 창을 시스템 폴더 · 루트가 같이 씀 (confirmReadOnlyView)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 13:34:21 +09:00
parent 5a5ecacb31
commit ab735396ac
9 changed files with 516 additions and 44 deletions

View File

@ -1209,6 +1209,17 @@
"로그인이 필요합니다 (403)": "Login required (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
"루마니아어": "Romanian",
"루트 (su) - 읽기 전용": "Root (su) - read-only",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.": "With root (su) permission, you can view all system folders and other apps' folders (such as /data). Other apps' personal data (chats, login information, passwords, etc.) may also be visible.",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.": "Views all system folders and other apps' folders with root (su) permission. Other apps' personal data (chats, login information, passwords, etc.) may also be visible. This app only reads (no writing, deleting or renaming), and exported files go only where you choose.",
"루트 (읽기 전용)": "Root (read-only)",
"루트 폴더 보기 (su · 읽기 전용)": "View root folders (su · read-only)",
"루트 폴더 보기 (읽기 전용)": "View root folders (read-only)",
"루트 폴더 보기를 켤까요?": "Turn on viewing root folders?",
"루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)": "Root folders are read-only (you can only export (copy))",
"루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)": "Cannot view without root permission (allow this app in your su app)",
"루트에 연결하지 못했습니다: {0}": "Could not connect to root: {0}",
"루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]": "On rooted devices, reads system and other apps' folders with su (read only). Tab [Root (read-only)] at the top of the file explorer",
"링크 {0} (건너뜀)": "Links {0} (skipped)",
"링크 {0}개는 넣지 않음": "{0} links not included",
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}",
@ -1312,6 +1323,7 @@
"목": "Thu",
"목록": "list",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요": "Only listing, viewing and exporting (copy) work. Other apps' data may be here, so handle exported files with care",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요": "Only listing, viewing and exporting (copy) work. System files and other apps' personal data are here, so handle exported files with care",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 글자 크기": "List text size",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "You can see them again with [Show hidden items] at the bottom of the list, or in Settings > File explorer.",
@ -2182,6 +2194,7 @@
"이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)",
"이 기기 ({0})": "This device ({0})",
"이 기기 메모리 {0}GB 기준:": "For this device's {0}GB of memory:",
"이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다": "This device has no su - this works only on rooted devices",
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "About {0} per image on this device (based on last time)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)",

View File

@ -1209,6 +1209,17 @@
"로그인이 필요합니다 (403)": "ログインが必要です (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
"루마니아어": "ルーマニア語",
"루트 (su) - 읽기 전용": "ルート (su) - 読み取り専用",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.": "ルート (su) 権限で、システム・他のアプリのフォルダー全体 (/data など) を見られるようになります。他のアプリの個人データ (チャット・ログイン情報・パスワードなど) も見える場合があります。",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.": "ルート (su) 権限でシステム・他のアプリのフォルダー全体を見ます。他のアプリの個人データ (チャット・ログイン情報・パスワードなど) も見える場合があります。このアプリは読み取りのみで (書き込み・削除・名前変更なし)、取り出したファイルは選んだ場所にだけ置きます。",
"루트 (읽기 전용)": "ルート (読み取り専用)",
"루트 폴더 보기 (su · 읽기 전용)": "ルートフォルダーを表示 (su・読み取り専用)",
"루트 폴더 보기 (읽기 전용)": "ルートフォルダーを表示 (読み取り専用)",
"루트 폴더 보기를 켤까요?": "ルートフォルダーの表示をオンにしますか?",
"루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)": "ルートフォルダーは読み取り専用です (取り出し (コピー) のみできます)",
"루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)": "ルートの許可がないため表示できません (su アプリでこのアプリを許可してください)",
"루트에 연결하지 못했습니다: {0}": "ルートに接続できませんでした: {0}",
"루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]": "ルート化した端末で su を使い、システム・他のアプリのフォルダーを読み取りだけします。エクスプローラー上部のタブ [ルート (読み取り専用)]",
"링크 {0} (건너뜀)": "リンク {0} (スキップ)",
"링크 {0}개는 넣지 않음": "リンク {0} 件は含めず",
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}",
@ -1312,6 +1323,7 @@
"목": "木",
"목록": "一覧",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요": "一覧・表示・取り出し (コピー) のみできます。他のアプリのデータがある場合があるので、取り出したファイルは注意して扱ってください",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요": "一覧・表示・取り出し (コピー) のみできます。システム・他のアプリの個人データがあるので、取り出したファイルは注意して扱ってください",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 글자 크기": "一覧の文字サイズ",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "リスト最下部の [隠した項目を表示] または 設定 > ファイルエクスプローラー で再び表示できます。",
@ -2182,6 +2194,7 @@
"이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)",
"이 기기 ({0})": "この端末 ({0})",
"이 기기 메모리 {0}GB 기준:": "この端末のメモリ {0}GB の場合:",
"이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다": "この端末には su がありません - ルート化した端末でのみ使えます",
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)",

View File

@ -1209,6 +1209,17 @@
"로그인이 필요합니다 (403)": "需要登录 (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
"루마니아어": "罗马尼亚语",
"루트 (su) - 읽기 전용": "Root (su) - 只读",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.": "借助 Root (su) 权限,可以查看系统及其他应用的全部文件夹 (如 /data)。其他应用的个人数据 (聊天、登录信息、密码等) 也可能可见。",
"루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.": "借助 Root (su) 权限查看系统及其他应用的全部文件夹。其他应用的个人数据 (聊天、登录信息、密码等) 也可能可见。本应用只读取 (不写入、不删除、不重命名),导出的文件只放在您选择的位置。",
"루트 (읽기 전용)": "Root (只读)",
"루트 폴더 보기 (su · 읽기 전용)": "查看 Root 文件夹 (su · 只读)",
"루트 폴더 보기 (읽기 전용)": "查看 Root 文件夹 (只读)",
"루트 폴더 보기를 켤까요?": "要开启查看 Root 文件夹吗?",
"루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)": "Root 文件夹为只读 (只能导出 (复制))",
"루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)": "没有 Root 授权,无法查看 (请在 su 应用中允许本应用)",
"루트에 연결하지 못했습니다: {0}": "无法连接到 Root: {0}",
"루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]": "在已 Root 的设备上通过 su 只读查看系统及其他应用的文件夹。文件管理器顶部标签 [Root (只读)]",
"링크 {0} (건너뜀)": "链接 {0}(跳过)",
"링크 {0}개는 넣지 않음": "{0} 个链接未包含",
"링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}",
@ -1312,6 +1323,7 @@
"목": "四",
"목록": "列表",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요": "只能列出、查看和导出 (复制)。这里可能有其他应用的数据,请谨慎处理导出的文件",
"목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요": "只能列出、查看和导出 (复制)。这里有系统及其他应用的个人数据,请谨慎处理导出的文件",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 글자 크기": "列表文字大小",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "可通过列表底部的 [显示隐藏项] 或 设置 > 文件管理器 重新查看。",
@ -2182,6 +2194,7 @@
"이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)",
"이 기기 ({0})": "本设备 ({0})",
"이 기기 메모리 {0}GB 기준:": "按本设备内存 {0}GB:",
"이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다": "此设备没有 su - 仅可在已 Root 的设备上使用",
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)",

View File

@ -492,6 +492,9 @@ class AppSettings {
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용, 사용자 결정 10/21 · 관리자 결정: 처음 끔 · 켤 때마다 경고)
bool shizukuEnabled = false;
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용, 관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 켤 때 · 처음 열 때 경고)
bool rootEnabled = false;
/// [마스터로도 열기] - 보관함 열쇠 한 벌을 안전 저장소 (마스터 비밀번호로 막힘) 에 둔다. 처음 끔. 마스터를 초기화하면 그 한 벌은 지워진다
bool vaultMasterCopy = false;
@ -901,6 +904,7 @@ class AppSettings {
'explorerKeyOverrides': explorerKeyOverrides,
'vaultEnabled': vaultEnabled,
'shizukuEnabled': shizukuEnabled,
'rootEnabled': rootEnabled,
'vaultMasterCopy': vaultMasterCopy,
'vaultAutoLockSeconds': vaultAutoLockSeconds,
'explorerFolderSorts': explorerFolderSorts,
@ -1149,6 +1153,7 @@ class AppSettings {
..explorerRemoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi'
..vaultEnabled = j['vaultEnabled'] == true
..shizukuEnabled = j['shizukuEnabled'] == true
..rootEnabled = j['rootEnabled'] == true
..vaultMasterCopy = j['vaultMasterCopy'] == true
..vaultAutoLockSeconds = j['vaultAutoLockSeconds'] is num ? (j['vaultAutoLockSeconds'] as num).round().clamp(0, 3600) : 60
..explorerKeyOverrides = j['explorerKeyOverrides'] is Map

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
@ -22,6 +24,114 @@ bool Function() shizukuSupported = () => Platform.isAndroid;
/// 탐색기 탭이 처음 여는 곳 (Android 11 부터 막힌 Android/data · obb 가 있는 폴더)
String shizukuHome(String storageRoot) => '$shizukuScheme$shizukuServer${storageRoot.replaceAll('\\', '/')}/Android';
/// X-plore 비교 5-2 루트 (관리자 결정 10/21: Shizuku 와 같은 틀 - 처음 끔 · 경고 두 번 · 주황 띠 · 고정 명령 셋 · 쓰기 길 없음).
/// 경로: `shz://root/<기기 절대 경로>` - 읽기 전용 막기를 시스템 폴더와 같이 쓴다
const rootServer = 'root';
bool isRootPath(String path) => path.startsWith('$shizukuScheme$rootServer/') || path == '$shizukuScheme$rootServer';
/// 루트 보기를 쓰는 기기 (Android - 시험에서 바꿈)
bool Function() rootSupported = () => Platform.isAndroid;
/// 루트 탭이 처음 여는 곳 (관리자 3a 결정 10/21: "/" - 다른 앱의 개인 자료가 있는 /data 를 고르지 않았는데 펼치지 않게)
const rootHome = '$shizukuScheme$rootServer/';
/// 셸에 넘기는 경로 (절대 경로만 · 줄바꿈 · \0 없음) 를 작은따옴표로 감싼다 - 사용자 글을 명령으로 넣지 않게
String shellPath(String path) {
if (!path.startsWith('/') || path.contains('\n') || path.contains('\u0000')) throw ArgumentError.value(path, 'path');
return "'${path.replaceAll("'", "'\\''")}'";
}
/// 고정 명령 셋 (Kotlin ShizukuBridge 와 같은 것): 목록 · 한 항목 정보 · 내용 읽기
String shellListScript(String path) =>
'cd ${shellPath(path)} && for f in * .[!.]* ..?*; do [ -e "\$f" ] || [ -L "\$f" ] || continue; stat -c \'%F|%s|%Y|%n\' -- "\$f"; done';
String shellStatScript(String path) => "stat -c '%F|%s|%Y|%n' -- ${shellPath(path)}";
String shellCatScript(String path) => 'cat -- ${shellPath(path)}';
/// su 로 (루팅한 기기). 이번 실행에서 허락을 받았는지만 기억한다 (허락 · 거절은 Magisk 같은 su 앱이 기억)
class SuRoot implements ShizukuBackend {
/// 프로세스 시작 (시험에서 바꿈)
static Future<Process> Function(String exe, List<String> args) start = (exe, args) => Process.start(exe, args);
static bool _granted = false;
@visibleForTesting
static void resetForTest() => _granted = false;
Future<(int, String, String)> _run(String exe, List<String> args, {Duration timeout = const Duration(seconds: 60)}) async {
final p = await start(exe, args);
unawaited(p.stdin.close());
final out = p.stdout.transform(const Utf8Decoder(allowMalformed: true)).join();
final err = p.stderr.transform(const Utf8Decoder(allowMalformed: true)).join();
final code = await p.exitCode.timeout(timeout, onTimeout: () {
p.kill();
return -1;
});
return (code, await out, await err);
}
Future<String> _text(String script) async {
final (code, out, err) = await _run('su', ['-c', script]);
if (code != 0 && out.isEmpty) throw FileSystemException(err.trim().isEmpty ? 'exit $code' : err.trim());
return out;
}
/// running = su 가 있는지 (묻는 창은 아직 띄우지 않음)
@override
Future<({bool running, bool granted})> status() async {
try {
final (code, out, _) = await _run('sh', ['-c', 'command -v su'], timeout: const Duration(seconds: 10));
return (running: code == 0 && out.trim().isNotEmpty, granted: _granted);
} catch (_) {
return (running: false, granted: false);
}
}
/// su 앱의 허락 창 (id -u 가 0 이면 허락)
@override
Future<bool> request() async {
try {
final (code, out, _) = await _run('su', ['-c', 'id -u']);
return _granted = code == 0 && out.trim() == '0';
} catch (_) {
return _granted = false;
}
}
@override
Future<String> list(String path) => _text(shellListScript(path));
@override
Future<String> stat(String path) async {
try {
return await _text(shellStatScript(path));
} on FileSystemException {
return '';
}
}
@override
Future<int> copyTo(String path, String dest) async {
final p = await start('su', ['-c', shellCatScript(path)]);
unawaited(p.stdin.close());
final err = p.stderr.transform(const Utf8Decoder(allowMalformed: true)).join();
final f = File(dest);
final sink = f.openWrite();
try {
await sink.addStream(p.stdout);
} finally {
await sink.close();
}
final code = await p.exitCode;
if (code != 0) {
await f.delete().catchError((_) => f);
final e = (await err).trim();
throw FileSystemException(e.isEmpty ? 'exit $code' : e, path);
}
return f.length();
}
}
/// 기기와 이야기하는 쪽 (시험에서 바꿔 끼움)
abstract class ShizukuBackend {
/// Shizuku 가 켜져 있는지 · 이 앱이 허락을 받았는지
@ -82,17 +192,26 @@ class ShizukuReadOnlyException extends FileSystemException {
class ShizukuRemote implements RemoteClient {
static ShizukuBackend backend = AndroidShizuku();
/// 루트 (su) 쪽 - 시험에서 바꿈
static ShizukuBackend rootBackend = SuRoot();
/// 내용을 잠깐 받아 둘 앱 전용 폴더 (시험에서 바꿈)
static Future<String> 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;
String _abs(String rel) => rel.isEmpty ? '/' : rel;
@override
Future<List<DavItem>> list(String rel) async {
final out = <DavItem>[];
for (final line in (await backend.list(_abs(rel))).split('\n')) {
for (final line in (await _b.list(_abs(rel))).split('\n')) {
final s = parseShizukuStat(line.trimRight());
if (s == null) continue;
final (dir, size, modified, name) = s;
@ -104,7 +223,7 @@ class ShizukuRemote implements RemoteClient {
@override
Future<DavItem?> stat(String rel) async {
final s = parseShizukuStat((await backend.stat(_abs(rel))).trim());
final s = parseShizukuStat((await _b.stat(_abs(rel))).trim());
if (s == null) return null;
return DavItem(rel, isDir: s.$1, size: s.$1 ? 0 : s.$2, modified: s.$3);
}
@ -116,7 +235,7 @@ class ShizukuRemote implements RemoteClient {
await dir.create(recursive: true);
final f = File(p.join(dir.path, 'f'));
try {
await backend.copyTo(_abs(rel), f.path);
await _b.copyTo(_abs(rel), f.path);
} catch (_) {
await dir.delete(recursive: true).catchError((_) => dir);
rethrow;

View File

@ -83,7 +83,7 @@ class DavPath {
/// 이 서버의 클라이언트 (WebDAV · SFTP · SMB)
RemoteClient get client => isShizuku
? ShizukuRemote.instance
? (server == rootServer ? ShizukuRemote.root : ShizukuRemote.instance)
: isSftp
? SftpRegistry.client(server)
: isSmb
@ -94,7 +94,7 @@ class DavPath {
/// 서버 이름 (화면 표시)
String get serverLabel => isShizuku
? tr('시스템 (읽기 전용)')
? (server == rootServer ? tr('루트 (읽기 전용)') : tr('시스템 (읽기 전용)'))
: isSftp
? SftpRegistry.server(server)?.label ?? 'SFTP'
: isSmb

View File

@ -130,6 +130,9 @@ class ExplorerPage extends StatefulWidget {
/// 이번 실행에서 시스템 폴더 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번)
static bool shizukuWarnedThisRun = false;
/// 루트도 같게 (따로 센다)
static bool rootWarnedThisRun = false;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@visibleForTesting
static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input);
@ -397,6 +400,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
// X-plore 비교 5-2: 시스템 폴더 (Shizuku, 읽기 전용) - 환경 설정에서 켰을 때 (Android)
if (c.settings.shizukuEnabled && shizukuSupported())
('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
// X-plore 비교 5-2: 루트 (su, 읽기 전용) - 환경 설정에서 켰을 때 (Android)
if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
];
late final List<_Pane> _panes = [_Pane(''), _Pane('')];
int _active = 0;
@ -599,15 +604,57 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
Widget? _shizukuNotice(_Pane pane) {
if (!isShizuku(pane.current)) return null;
final root = isRootPath(pane.current);
return _notice(
icon: Icons.admin_panel_settings_outlined,
title: tr('시스템 폴더 (Shizuku) - 읽기 전용'),
body: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
body: root
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
actions: const [],
color: Colors.orangeAccent,
);
}
/// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정)
Future<void> _openRoot(_Pane pane) async {
if (!ExplorerPage.rootWarnedThisRun) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.security, color: Colors.orangeAccent),
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 || !mounted) return;
ExplorerPage.rootWarnedThisRun = true;
}
try {
final st = await ShizukuRemote.rootBackend.status();
if (!st.running) {
_snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다'));
return;
}
if (!st.granted && !await ShizukuRemote.rootBackend.request()) {
_snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)'));
return;
}
} catch (e) {
_snack(trf('루트에 연결하지 못했습니다: {0}', [e]));
return;
}
if (!mounted) return;
setState(() => _active = _panes.indexOf(pane));
await _goTo(pane, rootHome);
}
/// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로
Future<void> _openShizuku(_Pane pane) async {
if (!ExplorerPage.shizukuWarnedThisRun) {
@ -2546,7 +2593,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
bool _blockArchive(List<String> paths, {bool self = false}) {
// X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만)
if (paths.any(isShizuku)) {
_snack(tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
_snack(paths.any(isRootPath)
? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')
: tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
return true;
}
if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(x)))) return false;
@ -5492,7 +5541,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
),
icon: Icon(
isShizuku(path)
isRootPath(path)
? Icons.security
: isShizuku(path)
? Icons.admin_panel_settings_outlined
: isSftp(path)
? Icons.vpn_key_outlined
@ -5515,7 +5566,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
: Text(label),
onPressed: () {
setState(() => _active = _panes.indexOf(pane));
if (isShizuku(path)) {
if (isRootPath(path)) {
unawaited(_openRoot(pane));
} else if (isShizuku(path)) {
unawaited(_openShizuku(pane));
} else if (_driveOk[path] == false) {
unawaited(_reconnect(pane, path));

View File

@ -34,7 +34,7 @@ import 'smb_settings.dart';
import 'webdav_settings.dart';
import 'explorer_look.dart' show ExplorerStyle;
import 'explorer_keys_page.dart';
import '../core/shizuku.dart' show shizukuSupported;
import '../core/shizuku.dart' show rootSupported, shizukuSupported;
import 'theme.dart';
import '../l10n/tr.dart';
import 'setting_tile.dart';
@ -847,43 +847,38 @@ class _SettingsPageState extends State<SettingsPage> {
trailing: Switch(
value: s.shizukuEnabled,
onChanged: (v) async {
if (v) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) {
var sure = false;
return StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.admin_panel_settings_outlined, color: Colors.orangeAccent),
title: Text(tr('시스템 폴더 보기를 켤까요?')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('Shizuku (adb 권한) 로 다른 앱의 폴더 (Android/data · obb) 를 볼 수 있게 됩니다. 그 안에는 다른 앱의 '
'대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.')),
const SizedBox(height: 6),
Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: sure,
onChanged: (x) => set(() => sure = x ?? false),
title: Text(tr('위 내용을 알고 켭니다')),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))),
],
),
);
},
);
if (ok != true) return;
if (v &&
!await confirmReadOnlyView(context,
icon: Icons.admin_panel_settings_outlined,
title: tr('시스템 폴더 보기를 켤까요?'),
body: tr('Shizuku (adb 권한) 로 다른 앱의 폴더 (Android/data · obb) 를 볼 수 있게 됩니다. 그 안에는 다른 앱의 '
'대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.'))) {
return;
}
await c.updateSettings((x) => x.shizukuEnabled = v);
},
),
),
// X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀
if (rootSupported())
SettingTile(
title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')),
subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')),
trailing: Switch(
value: s.rootEnabled,
onChanged: (v) async {
if (v &&
!await confirmReadOnlyView(context,
icon: Icons.security,
title: tr('루트 폴더 보기를 켤까요?'),
body: tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 '
'(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) {
return;
}
await c.updateSettings((x) => x.rootEnabled = v);
},
),
),
// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 (처음: 끔)
SettingTile(
title: Text(tr('종류별 기본 정렬')),
@ -1653,3 +1648,37 @@ class _SettingsPageState extends State<SettingsPage> {
]),
);
}
/// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true
Future<bool> confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) {
var sure = false;
return StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
icon: Icon(icon, color: Colors.orangeAccent),
title: Text(title),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(body),
const SizedBox(height: 6),
Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: sure,
onChanged: (x) => set(() => sure = x ?? false),
title: Text(tr('위 내용을 알고 켭니다')),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))),
],
),
);
},
);
return ok == true;
}

227
test/root_test.dart Normal file
View File

@ -0,0 +1,227 @@
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;
/// 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 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;
ExplorerPage.debugVolumes = null;
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
..rootEnabled = on
..explorerPaths = [tmp.path, tmp.path]
..explorerLayout = '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);
ExplorerPage.debugVolumes = () => [('/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(), c: ctl(on: false)))));
await wait(t, () => false);
expect(find.text('루트 (읽기 전용)'), findsNothing);
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), 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);
ExplorerPage.debugVolumes = () => [('/storage/emulated/0', 'T:')];
ExplorerPage.rootWarnedThisRun = true;
su.hasSu = false;
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), 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.rootEnabled, isTrue);
await t.tap(sw.first);
await settle();
expect(c.settings.rootEnabled, isFalse);
});
}