WIP X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단, Android): 가운데 버튼 [앱 관리자] (Android 만, 버튼 끝에 - 좁으면 [더 보기]) - 모든 앱 목록 권한 (QUERY_ALL_PACKAGES) 없이 매니페스트 queries 의 LAUNCHER 로 실행할 수 있는 앱만, 화면 위에 "보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)". 이름 · 판 · 크기 · 설치일, 이름 · 크기 · 설치일 정렬 · 찾기, [APK 백업] (Download/JJ_MKVMaker/apk, 이름_판.apk · 겹치면 (2), 나눠진 앱은 알림) · [앱 정보] (시스템 화면) · [지우기] (시스템 지우기 창, 시스템 앱은 없음). 아이콘은 그때그때 PNG 로 · 시험 (지도 읽기 · 백업 이름 · 화면 · 버튼은 Android 만) · APK 빌드로 Kotlin 확인 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 11:15:28 +09:00
parent 59f417552a
commit fffb9d7dbc
9 changed files with 487 additions and 2 deletions

View File

@ -159,6 +159,11 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<!-- X-plore 비교 5-2 앱 관리자 (관리자 판단 10/21): 모든 앱 목록 권한 (QUERY_ALL_PACKAGES) 대신 실행할 수 있는 앱 (런처에 보이는 앱) 만 -->
<intent>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>

View File

@ -166,6 +166,24 @@ class MainActivity : FlutterActivity() {
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
) )
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: "")) "openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만)
"launcherApps" -> inBackground(result) { launcherApps() }
"appIcon" -> inBackground(result) { appIcon(call.argument<String>("package") ?: "", call.argument<Int>("size") ?: 96) }
"appDetails" -> result.success(try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument<String>("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<String>("package") ?: "")))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
})
// X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히) // X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
"mediaScan" -> result.success(try { "mediaScan" -> result.success(try {
val paths = call.argument<List<String>>("paths") ?: emptyList() val paths = call.argument<List<String>>("paths") ?: emptyList()
@ -554,6 +572,49 @@ class MainActivity : FlutterActivity() {
} }
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서 /// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
/// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지)
private fun launcherApps(): List<Map<String, Any?>> {
val pm = packageManager
val seen = HashSet<String>()
val out = ArrayList<Map<String, Any?>>()
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?) { private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
Thread { Thread {
try { try {

View File

@ -143,6 +143,10 @@
"API 키 (선택)": "API key (optional)", "API 키 (선택)": "API key (optional)",
"API 키 (필수, 무료)": "API key (required, free)", "API 키 (필수, 무료)": "API key (required, free)",
"API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)", "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.)", "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 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.", "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", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder",
"보안": "Security", "보안": "Security",
"보이기": "Show", "보이기": "Show",
"보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)": "Only visible apps are listed (the all-apps permission is not used)",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "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.", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
"보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)", "보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)",
@ -1674,6 +1679,7 @@
"설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}", "설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}",
"설정을 백업으로 되살렸습니다": "Settings restored from backup", "설정을 백업으로 되살렸습니다": "Settings restored from backup",
"설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}", "설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}",
"설치 {0}": "Installed {0}",
"설치 계속": "Continue install", "설치 계속": "Continue install",
"설치 실패": "Install failed", "설치 실패": "Install failed",
"설치 준비 완료": "Ready to install", "설치 준비 완료": "Ready to install",
@ -1684,6 +1690,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.", "설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.",
"설치됨": "Installed", "설치됨": "Installed",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "Installed: Tor Expert Bundle {0} (official Tor release). Turning it off deletes it.", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "Installed: Tor Expert Bundle {0} (official Tor release). Turning it off deletes it.",
"설치일": "Install date",
"설치하는 중": "Installing", "설치하는 중": "Installing",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)",
"설치하지 못했습니다.\n{0}": "Could not install.\n{0}", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}",
@ -1841,7 +1848,9 @@
"앞의 버튼 보기": "Previous buttons", "앞의 버튼 보기": "Previous buttons",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
"앨범": "Album", "앨범": "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", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "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.", "앱 목록이 열렸다면, 목록에서 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)", "앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "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 · 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)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "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}", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "Couldn't open it in the app, opening with another app: {0}",
"앱 정보 (시스템 화면)": "App info (system screen)",
"앱 지우기": "Uninstall app", "앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -2213,6 +2223,7 @@
"이름 틀": "Name pattern", "이름 틀": "Name pattern",
"이름 틀 (확장자는 그대로)": "Name pattern (extension kept)", "이름 틀 (확장자는 그대로)": "Name pattern (extension kept)",
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "The name contains a line break and can't be sent over FTP", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "The name contains a line break and can't be sent over FTP",
"이름으로 찾기": "Search by name",
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
"이름을 바꿉니다": "renames it", "이름을 바꿉니다": "renames it",
@ -2546,6 +2557,7 @@
"지우고 맞추기": "Delete and sync", "지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset", "지우고 초기화": "Delete and reset",
"지우기": "Delete", "지우기": "Delete",
"지우기 (시스템 창에서 확인)": "Uninstall (confirm in the system dialog)",
"지우기 (확인 · 휴지통)": "Delete (confirm · trash)", "지우기 (확인 · 휴지통)": "Delete (confirm · trash)",
"지우기 포함": "with delete", "지우기 포함": "with delete",
"지우기가 들어 있는 작업입니다": "This task deletes files", "지우기가 들어 있는 작업입니다": "This task deletes files",

View File

@ -143,6 +143,10 @@
"API 키 (선택)": "API キー (任意)", "API 키 (선택)": "API キー (任意)",
"API 키 (필수, 무료)": "API キー(必須・無料)", "API 키 (필수, 무료)": "API キー(必須・無料)",
"API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)", "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 版はできます)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)",
"Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。", "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 を通らずに開く恐れがあるため、この画面はページを読み込みません。", "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 に変更しました。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
"보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)", "보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)",
@ -1674,6 +1679,7 @@
"설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}", "설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}",
"설정을 백업으로 되살렸습니다": "設定をバックアップから復元しました", "설정을 백업으로 되살렸습니다": "設定をバックアップから復元しました",
"설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}", "설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}",
"설치 {0}": "インストール {0}",
"설치 계속": "インストールを続ける", "설치 계속": "インストールを続ける",
"설치 실패": "インストール失敗", "설치 실패": "インストール失敗",
"설치 준비 완료": "インストール準備完了", "설치 준비 완료": "インストール準備完了",
@ -1684,6 +1690,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。", "설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。",
"설치됨": "インストール済み", "설치됨": "インストール済み",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "インストール済み: Tor Expert Bundle {0} (Tor 公式配布)。オフにすると削除します。", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "インストール済み: Tor Expert Bundle {0} (Tor 公式配布)。オフにすると削除します。",
"설치일": "インストール日",
"설치하는 중": "インストール中", "설치하는 중": "インストール中",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)",
"설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}",
@ -1841,7 +1848,9 @@
"앞의 버튼 보기": "前のボタンを表示", "앞의 버튼 보기": "前のボタンを表示",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앨범": "アルバム", "앨범": "アルバム",
"앱 관리자": "アプリ管理",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
"앱 목록을 읽지 못했습니다: {0}": "アプリ一覧を読み込めませんでした: {0}",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。",
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)", "앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)",
@ -1854,6 +1863,7 @@
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}",
"앱 정보 (시스템 화면)": "アプリ情報 (システム画面)",
"앱 지우기": "アプリを削除", "앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -2213,6 +2223,7 @@
"이름 틀": "名前の形式", "이름 틀": "名前の形式",
"이름 틀 (확장자는 그대로)": "名前の形式 (拡張子はそのまま)", "이름 틀 (확장자는 그대로)": "名前の形式 (拡張子はそのまま)",
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名前に改行が含まれているため FTP で送れません", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名前に改行が含まれているため FTP で送れません",
"이름으로 찾기": "名前で探す",
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
"이름을 바꿉니다": "名前を変えます", "이름을 바꿉니다": "名前を変えます",
@ -2546,6 +2557,7 @@
"지우고 맞추기": "削除して同期", "지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化", "지우고 초기화": "削除して初期化",
"지우기": "削除", "지우기": "削除",
"지우기 (시스템 창에서 확인)": "アンインストール (システム画面で確認)",
"지우기 (확인 · 휴지통)": "削除 (確認・ごみ箱)", "지우기 (확인 · 휴지통)": "削除 (確認・ごみ箱)",
"지우기 포함": "削除あり", "지우기 포함": "削除あり",
"지우기가 들어 있는 작업입니다": "削除を含む作業です", "지우기가 들어 있는 작업입니다": "削除を含む作業です",

View File

@ -143,6 +143,10 @@
"API 키 (선택)": "API 密钥 (可选)", "API 키 (선택)": "API 密钥 (可选)",
"API 키 (필수, 무료)": "API 密钥(必填,免费)", "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 版可以)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)",
"Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。", "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 打开,此画面不会加载任何页面。", "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。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
"보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)", "보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)",
@ -1674,6 +1679,7 @@
"설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}", "설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}",
"설정을 백업으로 되살렸습니다": "已从备份恢复设置", "설정을 백업으로 되살렸습니다": "已从备份恢复设置",
"설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}", "설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}",
"설치 {0}": "安装于 {0}",
"설치 계속": "继续安装", "설치 계속": "继续安装",
"설치 실패": "安装失败", "설치 실패": "安装失败",
"설치 준비 완료": "准备安装", "설치 준비 완료": "准备安装",
@ -1684,6 +1690,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。", "설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。",
"설치됨": "已安装", "설치됨": "已安装",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "已安装: Tor Expert Bundle {0} (Tor 官方发布)。关闭时删除。", "설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "已安装: Tor Expert Bundle {0} (Tor 官方发布)。关闭时删除。",
"설치일": "安装日期",
"설치하는 중": "正在安装", "설치하는 중": "正在安装",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)",
"설치하지 못했습니다.\n{0}": "未能安装。\n{0}", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}",
@ -1841,7 +1848,9 @@
"앞의 버튼 보기": "前面的按钮", "앞의 버튼 보기": "前面的按钮",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앨범": "专辑", "앨범": "专辑",
"앱 관리자": "应用管理",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
"앱 목록을 읽지 못했습니다: {0}": "无法读取应用列表: {0}",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。",
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)", "앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)",
@ -1854,6 +1863,7 @@
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}", "앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}",
"앱 정보 (시스템 화면)": "应用信息 (系统界面)",
"앱 지우기": "卸载应用", "앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -2213,6 +2223,7 @@
"이름 틀": "名称模式", "이름 틀": "名称模式",
"이름 틀 (확장자는 그대로)": "名称模式 (保留扩展名)", "이름 틀 (확장자는 그대로)": "名称模式 (保留扩展名)",
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名称中含有换行,无法通过 FTP 发送", "이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名称中含有换行,无法通过 FTP 发送",
"이름으로 찾기": "按名称查找",
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
"이름을 바꿉니다": "重命名", "이름을 바꿉니다": "重命名",
@ -2546,6 +2557,7 @@
"지우고 맞추기": "删除并同步", "지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置", "지우고 초기화": "删除并重置",
"지우기": "删除", "지우기": "删除",
"지우기 (시스템 창에서 확인)": "卸载 (在系统对话框中确认)",
"지우기 (확인 · 휴지통)": "删除 (确认 · 回收站)", "지우기 (확인 · 휴지통)": "删除 (确认 · 回收站)",
"지우기 포함": "含删除", "지우기 포함": "含删除",
"지우기가 들어 있는 작업입니다": "此任务包含删除", "지우기가 들어 있는 작업입니다": "此任务包含删除",

90
lib/core/app_manager.dart Normal file
View File

@ -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) 은 쓰지 않고
/// 매니페스트 `<queries>` 의 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<Object?, Object?> 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<InstalledApp>> list();
Future<Uint8List?> icon(String package);
Future<bool> details(String package);
Future<bool> uninstall(String package);
/// APK 백업 폴더 (Download/JJ_MKVMaker/apk)
Future<String> backupDir();
}
class AndroidAppManager implements AppManagerBackend {
static const _ch = MethodChannel('jj_mkvmaker/android');
@override
Future<List<InstalledApp>> list() async {
final l = await _ch.invokeMethod<List<Object?>>('launcherApps') ?? const [];
return [for (final m in l) if (m is Map) InstalledApp.fromMap(m)];
}
@override
Future<Uint8List?> icon(String package) => _ch.invokeMethod<Uint8List>('appIcon', {'package': package, 'size': 96});
@override
Future<bool> details(String package) async => await _ch.invokeMethod<bool>('appDetails', {'package': package}) ?? false;
@override
Future<bool> uninstall(String package) async => await _ch.invokeMethod<bool>('uninstallApp', {'package': package}) ?? false;
@override
Future<String> backupDir() async {
final root = await _ch.invokeMethod<String>('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<String> 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;
}

View File

@ -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<void> openAppManager(BuildContext context, {AppManagerBackend? backend}) => Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => AppManagerPage(backend: backend ?? AndroidAppManager())));
class AppManagerPage extends StatefulWidget {
final AppManagerBackend backend;
const AppManagerPage({super.key, required this.backend});
@override
State<AppManagerPage> createState() => _AppManagerPageState();
}
class _AppManagerPageState extends State<AppManagerPage> {
List<InstalledApp>? _apps;
String? _error;
String _sort = 'name';
String _filter = '';
final _icons = <String, Future<Uint8List?>>{};
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
try {
final l = await widget.backend.list();
if (mounted) setState(() => _apps = l);
} catch (e) {
if (mounted) setState(() => _error = trf('앱 목록을 읽지 못했습니다: {0}', [e]));
}
}
List<InstalledApp> get _shown {
final f = _filter.toLowerCase();
final l = [for (final a in _apps ?? const <InstalledApp>[]) 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<void> _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<void> _menu(InstalledApp a) async {
final w = await showModalBottomSheet<String>(
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<String>(
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<Uint8List?>(
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),
);
},
),
),
]),
);
}
}

View File

@ -58,6 +58,7 @@ import '../l10n/tr.dart';
import 'explorer_bookmarks.dart'; import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'remote_thumb_view.dart'; import 'remote_thumb_view.dart';
import 'app_manager_page.dart' show openAppManager;
import 'dlna_page.dart' show openDlna; import 'dlna_page.dart' show openDlna;
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
@ -201,7 +202,9 @@ enum ExplorerButton {
// 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용)
wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'), wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'),
// X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) // 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 IconData icon;
final String label; final String label;
@ -211,7 +214,10 @@ enum ExplorerButton {
bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor;
/// 파일 탐색기에서 고를 수 있는 버튼 /// 파일 탐색기에서 고를 수 있는 버튼
static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b];
/// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈)
static bool Function() appsButtonSupported = () => Platform.isAndroid;
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
// 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽
@ -2664,6 +2670,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _storage(pane, pane.current); await _storage(pane, pane.current);
case ExplorerButton.wifiShare: case ExplorerButton.wifiShare:
await _wifiShare(pane.current); await _wifiShare(pane.current);
case ExplorerButton.apps:
await openAppManager(context);
case ExplorerButton.trash: case ExplorerButton.trash:
await _openTrash(); await _openTrash();
case ExplorerButton.sort: case ExplorerButton.sort:

120
test/app_manager_test.dart Normal file
View File

@ -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<InstalledApp> apps;
final String dir;
final calls = <String>[];
_Fake(this.apps, this.dir);
@override
Future<List<InstalledApp>> list() async => apps;
@override
Future<Uint8List?> icon(String package) async => null;
@override
Future<bool> details(String package) async {
calls.add('details:$package');
return true;
}
@override
Future<bool> uninstall(String package) async {
calls.add('uninstall:$package');
return true;
}
@override
Future<String> 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<void>.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));
});
}