From fffb9d7dbcadd987a6d6bf16869507a9b72dadaf Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:15:28 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20=EB=B9=84=EA=B5=90=205-2=20?= =?UTF-8?q?=EC=95=B1=20=EA=B4=80=EB=A6=AC=EC=9E=90=20(=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=B0=EC=A0=95=2010/21,=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=8C=90=EB=8B=A8,=20Android):=20=EA=B0=80?= =?UTF-8?q?=EC=9A=B4=EB=8D=B0=20=EB=B2=84=ED=8A=BC=20[=EC=95=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90]=20(Android=20=EB=A7=8C,=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=81=9D=EC=97=90=20-=20=EC=A2=81?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20[=EB=8D=94=20=EB=B3=B4=EA=B8=B0])=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=95=B1=20=EB=AA=A9=EB=A1=9D=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20(QUERY=5FALL=5FPACKAGES)=20=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4=ED=8A=B8=20queries=20?= =?UTF-8?q?=EC=9D=98=20LAUNCHER=20=EB=A1=9C=20=EC=8B=A4=ED=96=89=ED=95=A0?= =?UTF-8?q?=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=95=B1=EB=A7=8C,=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9C=84=EC=97=90=20"=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8A=94=20=EC=95=B1=EB=A7=8C=20=EB=82=98=EC=98=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4=20(=EB=AA=A8=EB=93=A0=20=EC=95=B1=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B6=8C=ED=95=9C=EC=9D=80=20=EC=93=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=8C)".=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=20=C2=B7=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EC=9D=BC,=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=84=A4=EC=B9=98=EC=9D=BC=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B0=BE=EA=B8=B0,=20[APK=20=EB=B0=B1=EC=97=85]=20(Do?= =?UTF-8?q?wnload/JJ=5FMKVMaker/apk,=20=EC=9D=B4=EB=A6=84=5F=ED=8C=90.apk?= =?UTF-8?q?=20=C2=B7=20=EA=B2=B9=EC=B9=98=EB=A9=B4=20(2),=20=EB=82=98?= =?UTF-8?q?=EB=88=A0=EC=A7=84=20=EC=95=B1=EC=9D=80=20=EC=95=8C=EB=A6=BC)?= =?UTF-8?q?=20=C2=B7=20[=EC=95=B1=20=EC=A0=95=EB=B3=B4]=20(=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=ED=99=94=EB=A9=B4)=20=C2=B7=20[=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0]=20(=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=EC=B0=BD,=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=20=EC=95=B1=EC=9D=80=20=EC=97=86=EC=9D=8C).=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EC=9D=80=20=EA=B7=B8=EB=95=8C=EA=B7=B8?= =?UTF-8?q?=EB=95=8C=20PNG=20=EB=A1=9C=20=C2=B7=20=EC=8B=9C=ED=97=98=20(?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=B2=84=ED=8A=BC=EC=9D=80=20Android=20=EB=A7=8C)=20?= =?UTF-8?q?=C2=B7=20APK=20=EB=B9=8C=EB=93=9C=EB=A1=9C=20Kotlin=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/AndroidManifest.xml | 5 + .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 61 +++++++ assets/l10n/en.json | 12 ++ assets/l10n/ja.json | 12 ++ assets/l10n/zh-Hans.json | 12 ++ lib/core/app_manager.dart | 90 ++++++++++ lib/ui/app_manager_page.dart | 165 ++++++++++++++++++ lib/ui/explorer_page.dart | 12 +- test/app_manager_test.dart | 120 +++++++++++++ 9 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 lib/core/app_manager.dart create mode 100644 lib/ui/app_manager_page.dart create mode 100644 test/app_manager_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 418e8d8..ada7aba 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -159,6 +159,11 @@ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> + + + + + diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index a4c770f..323345d 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -166,6 +166,24 @@ class MainActivity : FlutterActivity() { Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() ) "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) + // X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만) + "launcherApps" -> inBackground(result) { launcherApps() } + "appIcon" -> inBackground(result) { appIcon(call.argument("package") ?: "", call.argument("size") ?: 96) } + "appDetails" -> result.success(try { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument("package") ?: ""))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + }) + // 지우기는 시스템의 지우기 창 (사용자가 거기서 확인) + "uninstallApp" -> result.success(try { + startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:" + (call.argument("package") ?: ""))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + }) // X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히) "mediaScan" -> result.success(try { val paths = call.argument>("paths") ?: emptyList() @@ -554,6 +572,49 @@ class MainActivity : FlutterActivity() { } /// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서 + /// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지) + private fun launcherApps(): List> { + val pm = packageManager + val seen = HashSet() + val out = ArrayList>() + for (ri in pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)) { + val pkg = ri.activityInfo.packageName + if (!seen.add(pkg)) continue + try { + @Suppress("DEPRECATION") + val pi = pm.getPackageInfo(pkg, 0) + val ai = pi.applicationInfo ?: continue + val dirs = listOfNotNull(ai.sourceDir) + (ai.splitSourceDirs?.toList() ?: emptyList()) + out.add(mapOf( + "package" to pkg, + "label" to ri.loadLabel(pm).toString(), + "version" to (pi.versionName ?: ""), + "installed" to pi.firstInstallTime, + "updated" to pi.lastUpdateTime, + "size" to dirs.sumOf { java.io.File(it).length() }, + "apk" to ai.sourceDir, + "splits" to (ai.splitSourceDirs?.size ?: 0), + "system" to ((ai.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0), + )) + } catch (e: Exception) { + } + } + return out + } + + /// 앱 아이콘 (PNG, [size] 픽셀) + private fun appIcon(pkg: String, size: Int): ByteArray? = try { + val d = packageManager.getApplicationIcon(pkg) + val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + d.setBounds(0, 0, size, size) + d.draw(android.graphics.Canvas(bmp)) + val bos = java.io.ByteArrayOutputStream() + bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, bos) + bos.toByteArray() + } catch (e: Exception) { + null + } + private fun inBackground(result: MethodChannel.Result, work: () -> Any?) { Thread { try { diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e428ef6..5c86fee 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -143,6 +143,10 @@ "API 키 (선택)": "API key (optional)", "API 키 (필수, 무료)": "API key (required, free)", "API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)", + "APK 를 저장하지 못했습니다: {0}": "Could not save the APK: {0}", + "APK 를 저장했습니다: {0}": "APK saved: {0}", + "APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "APK saved: {0} - this app is split ({1} parts), so this file alone may not install", + "APK 백업": "Back up APK", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Since Android 11, no file app can open other apps' data folders (Android/data · Android/obb). Use export or share inside that app to get the files.", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView lacks a required feature ({0}). Update Android System WebView / Chrome in the Play Store and open this screen again. Because pages could open without going through Tor, this screen loads nothing.", @@ -1458,6 +1462,7 @@ "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder", "보안": "Security", "보이기": "Show", + "보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)": "Only visible apps are listed (the all-apps permission is not used)", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.", "보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)", @@ -1674,6 +1679,7 @@ "설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}", "설정을 백업으로 되살렸습니다": "Settings restored from backup", "설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}", + "설치 {0}": "Installed {0}", "설치 계속": "Continue install", "설치 실패": "Install failed", "설치 준비 완료": "Ready to install", @@ -1684,6 +1690,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.", "설치됨": "Installed", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "Installed: Tor Expert Bundle {0} (official Tor release). Turning it off deletes it.", + "설치일": "Install date", "설치하는 중": "Installing", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}", @@ -1841,7 +1848,9 @@ "앞의 버튼 보기": "Previous buttons", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.", "앨범": "Album", + "앱 관리자": "App manager", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again", + "앱 목록을 읽지 못했습니다: {0}": "Could not read the app list: {0}", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.", "앱 설정 전체 (처음 상태로)": "All app settings (back to initial)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)", @@ -1854,6 +1863,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "Couldn't open it in the app, opening with another app: {0}", + "앱 정보 (시스템 화면)": "App info (system screen)", "앱 지우기": "Uninstall app", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).", "앱에 들어 있는 것 (기본)": "Built into the app (default)", @@ -2213,6 +2223,7 @@ "이름 틀": "Name pattern", "이름 틀 (확장자는 그대로)": "Name pattern (extension kept)", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "The name contains a line break and can't be sent over FTP", + "이름으로 찾기": "Search by name", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿉니다": "renames it", @@ -2546,6 +2557,7 @@ "지우고 맞추기": "Delete and sync", "지우고 초기화": "Delete and reset", "지우기": "Delete", + "지우기 (시스템 창에서 확인)": "Uninstall (confirm in the system dialog)", "지우기 (확인 · 휴지통)": "Delete (confirm · trash)", "지우기 포함": "with delete", "지우기가 들어 있는 작업입니다": "This task deletes files", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index cf21f5e..ec5e398 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -143,6 +143,10 @@ "API 키 (선택)": "API キー (任意)", "API 키 (필수, 무료)": "API キー(必須・無料)", "API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)", + "APK 를 저장하지 못했습니다: {0}": "APK を保存できませんでした: {0}", + "APK 를 저장했습니다: {0}": "APK を保存しました: {0}", + "APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "APK を保存しました: {0} - 分割されたアプリ (分割 {1} 個) のため、このファイルだけではインストールできない場合があります", + "APK 백업": "APK をバックアップ", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView に必要な機能がありません ({0})。Play ストアで Android System WebView・Chrome を更新してから開き直してください。Tor を通らずに開く恐れがあるため、この画面はページを読み込みません。", @@ -1458,6 +1462,7 @@ "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました", "보안": "セキュリティ", "보이기": "表示", + "보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)": "表示されるアプリだけが出ます (すべてのアプリ一覧の権限は使いません)", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)", @@ -1674,6 +1679,7 @@ "설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}", "설정을 백업으로 되살렸습니다": "設定をバックアップから復元しました", "설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}", + "설치 {0}": "インストール {0}", "설치 계속": "インストールを続ける", "설치 실패": "インストール失敗", "설치 준비 완료": "インストール準備完了", @@ -1684,6 +1690,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。", "설치됨": "インストール済み", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "インストール済み: Tor Expert Bundle {0} (Tor 公式配布)。オフにすると削除します。", + "설치일": "インストール日", "설치하는 중": "インストール中", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", @@ -1841,7 +1848,9 @@ "앞의 버튼 보기": "前のボタンを表示", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앨범": "アルバム", + "앱 관리자": "アプリ管理", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", + "앱 목록을 읽지 못했습니다: {0}": "アプリ一覧を読み込めませんでした: {0}", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。", "앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)", @@ -1854,6 +1863,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}", + "앱 정보 (시스템 화면)": "アプリ情報 (システム画面)", "앱 지우기": "アプリを削除", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", @@ -2213,6 +2223,7 @@ "이름 틀": "名前の形式", "이름 틀 (확장자는 그대로)": "名前の形式 (拡張子はそのまま)", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名前に改行が含まれているため FTP で送れません", + "이름으로 찾기": "名前で探す", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿉니다": "名前を変えます", @@ -2546,6 +2557,7 @@ "지우고 맞추기": "削除して同期", "지우고 초기화": "削除して初期化", "지우기": "削除", + "지우기 (시스템 창에서 확인)": "アンインストール (システム画面で確認)", "지우기 (확인 · 휴지통)": "削除 (確認・ごみ箱)", "지우기 포함": "削除あり", "지우기가 들어 있는 작업입니다": "削除を含む作業です", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 17b6352..76f4079 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -143,6 +143,10 @@ "API 키 (선택)": "API 密钥 (可选)", "API 키 (필수, 무료)": "API 密钥(必填,免费)", "API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)", + "APK 를 저장하지 못했습니다: {0}": "无法保存 APK: {0}", + "APK 를 저장했습니다: {0}": "已保存 APK: {0}", + "APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "已保存 APK: {0} - 此应用已拆分 ({1} 个部分),仅此文件可能无法安装", + "APK 백업": "备份 APK", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView 缺少所需功能 ({0})。请在 Play 商店更新 Android System WebView · Chrome 后重新打开。由于页面可能不经过 Tor 打开,此画面不会加载任何页面。", @@ -1458,6 +1462,7 @@ "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹", "보안": "安全", "보이기": "显示", + "보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)": "仅列出可见的应用 (不使用全部应用列表权限)", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)", @@ -1674,6 +1679,7 @@ "설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}", "설정을 백업으로 되살렸습니다": "已从备份恢复设置", "설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}", + "설치 {0}": "安装于 {0}", "설치 계속": "继续安装", "설치 실패": "安装失败", "설치 준비 완료": "准备安装", @@ -1684,6 +1690,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。", "설치됨": "已安装", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "已安装: Tor Expert Bundle {0} (Tor 官方发布)。关闭时删除。", + "설치일": "安装日期", "설치하는 중": "正在安装", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}", @@ -1841,7 +1848,9 @@ "앞의 버튼 보기": "前面的按钮", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앨범": "专辑", + "앱 관리자": "应用管理", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", + "앱 목록을 읽지 못했습니다: {0}": "无法读取应用列表: {0}", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。", "앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)", @@ -1854,6 +1863,7 @@ "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}", + "앱 정보 (시스템 화면)": "应用信息 (系统界面)", "앱 지우기": "卸载应用", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。", "앱에 들어 있는 것 (기본)": "应用内置 (默认)", @@ -2213,6 +2223,7 @@ "이름 틀": "名称模式", "이름 틀 (확장자는 그대로)": "名称模式 (保留扩展名)", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名称中含有换行,无法通过 FTP 发送", + "이름으로 찾기": "按名称查找", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿉니다": "重命名", @@ -2546,6 +2557,7 @@ "지우고 맞추기": "删除并同步", "지우고 초기화": "删除并重置", "지우기": "删除", + "지우기 (시스템 창에서 확인)": "卸载 (在系统对话框中确认)", "지우기 (확인 · 휴지통)": "删除 (确认 · 回收站)", "지우기 포함": "含删除", "지우기가 들어 있는 작업입니다": "此任务包含删除", diff --git a/lib/core/app_manager.dart b/lib/core/app_manager.dart new file mode 100644 index 0000000..688cefe --- /dev/null +++ b/lib/core/app_manager.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, Android). 관리자 판단: 모든 앱 목록 권한 (QUERY_ALL_PACKAGES) 은 쓰지 않고 +/// 매니페스트 `` 의 LAUNCHER 로 "실행할 수 있는 앱" (런처에 보이는 앱) 만 본다. +class InstalledApp { + final String package, label, version, apk; + final DateTime installed, updated; + final int size, splits; + final bool system; + const InstalledApp({ + required this.package, + required this.label, + required this.version, + required this.apk, + required this.installed, + required this.updated, + required this.size, + this.splits = 0, + this.system = false, + }); + + factory InstalledApp.fromMap(Map m) { + int n(String k) => (m[k] as num?)?.toInt() ?? 0; + return InstalledApp( + package: '${m['package'] ?? ''}', + label: '${m['label'] ?? m['package'] ?? ''}', + version: '${m['version'] ?? ''}', + apk: '${m['apk'] ?? ''}', + installed: DateTime.fromMillisecondsSinceEpoch(n('installed')), + updated: DateTime.fromMillisecondsSinceEpoch(n('updated')), + size: n('size'), + splits: n('splits'), + system: m['system'] == true, + ); + } +} + +/// 기기와 이야기하는 쪽 (시험에서 바꿔 끼움) +abstract class AppManagerBackend { + Future> list(); + Future icon(String package); + Future details(String package); + Future uninstall(String package); + + /// APK 백업 폴더 (Download/JJ_MKVMaker/apk) + Future backupDir(); +} + +class AndroidAppManager implements AppManagerBackend { + static const _ch = MethodChannel('jj_mkvmaker/android'); + + @override + Future> list() async { + final l = await _ch.invokeMethod>('launcherApps') ?? const []; + return [for (final m in l) if (m is Map) InstalledApp.fromMap(m)]; + } + + @override + Future icon(String package) => _ch.invokeMethod('appIcon', {'package': package, 'size': 96}); + + @override + Future details(String package) async => await _ch.invokeMethod('appDetails', {'package': package}) ?? false; + + @override + Future uninstall(String package) async => await _ch.invokeMethod('uninstallApp', {'package': package}) ?? false; + + @override + Future backupDir() async { + final root = await _ch.invokeMethod('storageRoot') ?? '/storage/emulated/0'; + return p.join(root, 'Download', 'JJ_MKVMaker', 'apk'); + } +} + +/// 파일 이름에 못 쓰는 글자를 _ 로 +String _safe(String s) => s.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + +/// APK 를 [dir] 에 복사한다 ("이름_판.apk", 이미 있으면 " (2)" …). 만든 파일 경로 +Future backupApk(InstalledApp app, String dir) async { + await Directory(dir).create(recursive: true); + final base = '${_safe(app.label)}_${_safe(app.version.isEmpty ? app.package : app.version)}'; + var out = p.join(dir, '$base.apk'); + for (var i = 2; await File(out).exists(); i++) { + out = p.join(dir, '$base ($i).apk'); + } + await File(app.apk).copy(out); + return out; +} diff --git a/lib/ui/app_manager_page.dart b/lib/ui/app_manager_page.dart new file mode 100644 index 0000000..27e1a7b --- /dev/null +++ b/lib/ui/app_manager_page.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../core/app_manager.dart'; +import '../core/file_ops.dart' show formatSize; +import '../core/vfs.dart' show vDisplay; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, Android): 실행할 수 있는 앱 목록 - 이름 · 판 · 크기 · 설치일, +/// [APK 백업] (Download/JJ_MKVMaker/apk) · [앱 정보] (시스템 화면) · [지우기] (시스템 지우기 창) +Future openAppManager(BuildContext context, {AppManagerBackend? backend}) => Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => AppManagerPage(backend: backend ?? AndroidAppManager()))); + +class AppManagerPage extends StatefulWidget { + final AppManagerBackend backend; + const AppManagerPage({super.key, required this.backend}); + + @override + State createState() => _AppManagerPageState(); +} + +class _AppManagerPageState extends State { + List? _apps; + String? _error; + String _sort = 'name'; + String _filter = ''; + final _icons = >{}; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + try { + final l = await widget.backend.list(); + if (mounted) setState(() => _apps = l); + } catch (e) { + if (mounted) setState(() => _error = trf('앱 목록을 읽지 못했습니다: {0}', [e])); + } + } + + List get _shown { + final f = _filter.toLowerCase(); + final l = [for (final a in _apps ?? const []) if (f.isEmpty || a.label.toLowerCase().contains(f) || a.package.contains(f)) a]; + switch (_sort) { + case 'size': + l.sort((a, b) => b.size.compareTo(a.size)); + case 'installed': + l.sort((a, b) => b.installed.compareTo(a.installed)); + default: + l.sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + } + return l; + } + + void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t))); + + String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + Future _backup(InstalledApp a) async { + try { + final out = await backupApk(a, await widget.backend.backupDir()); + _snack(a.splits > 0 + ? trf('APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다', [vDisplay(out), a.splits]) + : trf('APK 를 저장했습니다: {0}', [vDisplay(out)])); + } catch (e) { + _snack(trf('APK 를 저장하지 못했습니다: {0}', [e])); + } + } + + Future _menu(InstalledApp a) async { + final w = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + ListTile(title: Text(a.label), subtitle: Text(a.package)), + ListTile(leading: const Icon(Icons.save_alt), title: Text(tr('APK 백업')), onTap: () => Navigator.pop(ctx, 'backup')), + ListTile(leading: const Icon(Icons.info_outline), title: Text(tr('앱 정보 (시스템 화면)')), onTap: () => Navigator.pop(ctx, 'details')), + if (!a.system) + ListTile( + leading: const Icon(Icons.delete_outline, color: JjColors.danger), + title: Text(tr('지우기 (시스템 창에서 확인)')), + onTap: () => Navigator.pop(ctx, 'uninstall')), + ]), + ), + ); + switch (w) { + case 'backup': + await _backup(a); + case 'details': + await widget.backend.details(a.package); + case 'uninstall': + await widget.backend.uninstall(a.package); + } + } + + @override + Widget build(BuildContext context) { + final apps = _apps; + return Scaffold( + appBar: AppBar( + title: Text(tr('앱 관리자')), + actions: [IconButton(tooltip: tr('새로 고침'), icon: const Icon(Icons.refresh), onPressed: _load)], + ), + body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text(tr('보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row(children: [ + Expanded( + child: TextField( + decoration: InputDecoration(isDense: true, prefixIcon: const Icon(Icons.search, size: 18), hintText: tr('이름으로 찾기')), + onChanged: (v) => setState(() => _filter = v.trim()), + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _sort, + items: [ + DropdownMenuItem(value: 'name', child: Text(tr('이름'))), + DropdownMenuItem(value: 'size', child: Text(tr('크기'))), + DropdownMenuItem(value: 'installed', child: Text(tr('설치일'))), + ], + onChanged: (v) => setState(() => _sort = v!), + ), + ]), + ), + if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))), + if (apps == null && _error == null) const Expanded(child: Center(child: CircularProgressIndicator())), + if (apps != null) + Expanded( + child: ListView.builder( + itemCount: _shown.length, + itemBuilder: (_, i) { + final a = _shown[i]; + return ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: FutureBuilder( + future: _icons[a.package] ??= widget.backend.icon(a.package).catchError((_) => null), + builder: (_, s) => s.data == null ? const Icon(Icons.android) : Image.memory(s.data!, width: 40, height: 40), + ), + ), + title: Text(a.label, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text([if (a.version.isNotEmpty) a.version, formatSize(a.size), trf('설치 {0}', [_day(a.installed)])].join(' · '), + maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton(tooltip: tr('더 보기'), icon: const Icon(Icons.more_vert), onPressed: () => _menu(a)), + onTap: () => _menu(a), + ); + }, + ), + ), + ]), + ); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f9dcc8d..5c128aa 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -58,6 +58,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'remote_thumb_view.dart'; +import 'app_manager_page.dart' show openAppManager; import 'dlna_page.dart' show openDlna; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; @@ -201,7 +202,9 @@ enum ExplorerButton { // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'), // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) - trash(Icons.restore_from_trash_outlined, '휴지통'); + trash(Icons.restore_from_trash_outlined, '휴지통'), + // X-plore 비교 5-2 (사용자 결정 10/21): 앱 관리자 (Android 만) + apps(Icons.apps, '앱 관리자'); final IconData icon; final String label; @@ -211,7 +214,10 @@ enum ExplorerButton { bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; /// 파일 탐색기에서 고를 수 있는 버튼 - static List get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; + static List get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b]; + + /// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈) + static bool Function() appsButtonSupported = () => Platform.isAndroid; /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 @@ -2664,6 +2670,8 @@ class _ExplorerPageState extends State { await _storage(pane, pane.current); case ExplorerButton.wifiShare: await _wifiShare(pane.current); + case ExplorerButton.apps: + await openAppManager(context); case ExplorerButton.trash: await _openTrash(); case ExplorerButton.sort: diff --git a/test/app_manager_test.dart b/test/app_manager_test.dart new file mode 100644 index 0000000..2f24e35 --- /dev/null +++ b/test/app_manager_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/app_manager.dart'; +import 'package:jj_mkvmaker/ui/app_manager_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart' show ExplorerButton; +import 'package:path/path.dart' as p; + +/// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, Android): 보이는 앱만 · APK 백업 · 앱 정보 · 지우기 (시스템 창) +class _Fake implements AppManagerBackend { + final List apps; + final String dir; + final calls = []; + _Fake(this.apps, this.dir); + @override + Future> list() async => apps; + @override + Future icon(String package) async => null; + @override + Future details(String package) async { + calls.add('details:$package'); + return true; + } + + @override + Future uninstall(String package) async { + calls.add('uninstall:$package'); + return true; + } + + @override + Future backupDir() async => dir; +} + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_apps_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + InstalledApp app(String label, {int size = 1000, int day = 1, bool system = false, int splits = 0, String? apk}) => InstalledApp( + package: 'com.x.${label.toLowerCase()}', + label: label, + version: '1.2', + apk: apk ?? '', + installed: DateTime(2026, 1, day), + updated: DateTime(2026, 1, day), + size: size, + system: system, + splits: splits, + ); + + test('기기 답 (지도) 읽기 · APK 백업 이름 (못 쓰는 글자 _ · 이미 있으면 (2))', () async { + final a = InstalledApp.fromMap({'package': 'p', 'label': 'A/B', 'version': '3', 'apk': '/x', 'installed': 0, 'updated': 0, 'size': 5, 'splits': 2, 'system': true}); + expect((a.label, a.size, a.splits, a.system), ('A/B', 5, 2, true)); + final src = File(p.join(tmp.path, 'base.apk'))..writeAsStringSync('apk'); + final b = app('A/B', apk: src.path); + final out = p.join(tmp.path, 'out'); + final first = await backupApk(b, out); + final second = await backupApk(b, out); + expect(p.basename(first), 'A_B_1.2.apk'); + expect(p.basename(second), 'A_B_1.2 (2).apk'); + expect(File(second).readAsStringSync(), 'apk'); + }); + + testWidgets('화면: 안내 · 이름 / 크기 / 설치일 정렬 · 찾기 · [APK 백업] · [앱 정보] · 시스템 앱은 [지우기] 없음', (tester) async { + tester.view.physicalSize = const Size(900, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final src = File(p.join(tmp.path, 'b.apk'))..writeAsStringSync('apk'); + final fake = _Fake([ + app('Banana', size: 300, day: 5, apk: src.path, splits: 3), + app('Apple', size: 900, day: 1), + app('Settings', size: 100, day: 2, system: true), + ], p.join(tmp.path, 'Download', 'JJ_MKVMaker', 'apk')); + await tester.pumpWidget(MaterialApp(home: AppManagerPage(backend: fake))); + await tester.pumpAndSettle(); + expect(find.text('보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)'), findsOneWidget); + double y(String t) => tester.getTopLeft(find.text(t)).dy; + expect(y('Apple') < y('Banana') && y('Banana') < y('Settings'), isTrue, reason: '처음은 이름 순'); + await tester.tap(find.text('이름')); + await tester.pumpAndSettle(); + await tester.tap(find.text('크기').last); + await tester.pumpAndSettle(); + expect(y('Apple') < y('Banana'), isTrue, reason: '큰 것부터'); + await tester.enterText(find.byType(TextField), 'ban'); + await tester.pump(); + expect(find.text('Apple'), findsNothing); + // APK 백업 (나눠진 앱이면 알림) + await tester.tap(find.text('Banana')); + await tester.pumpAndSettle(); + expect(find.text('지우기 (시스템 창에서 확인)'), findsOneWidget); + await tester.tap(find.text('APK 백업')); + for (var i = 0; i < 20; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(File(p.join(fake.dir, 'Banana_1.2.apk')).existsSync(), isTrue); + expect(find.textContaining('나눠진 앱 (조각 3개)'), findsOneWidget); + // 시스템 앱: 지우기 없음 · 앱 정보는 시스템 화면 + await tester.enterText(find.byType(TextField), 'set'); + await tester.pump(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(find.text('지우기 (시스템 창에서 확인)'), findsNothing); + await tester.tap(find.text('앱 정보 (시스템 화면)')); + await tester.pumpAndSettle(); + expect(fake.calls, ['details:com.x.settings']); + }); + + test('가운데 버튼 [앱 관리자] 는 Android 에서만 고를 수 있음', () { + final was = ExplorerButton.appsButtonSupported; + addTearDown(() => ExplorerButton.appsButtonSupported = was); + ExplorerButton.appsButtonSupported = () => false; + expect(ExplorerButton.explorerValues, isNot(contains(ExplorerButton.apps))); + ExplorerButton.appsButtonSupported = () => true; + expect(ExplorerButton.explorerValues, contains(ExplorerButton.apps)); + }); +}