From c75317e947239f3ce4df6910f0757c066a2d7e98 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:11:43 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20MainActivity.kt?= =?UTF-8?q?=20(758)=20=EC=9D=98=20=EC=B1=84=EB=84=90=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EB=B3=84=20=EC=B2=98=EB=A6=AC=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EB=A1=9C=20(=E2=86=92=2086=20+=205=EA=B0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelModule.kt (31): 처리기 약속 (handle → 맡으면 true) · inBackground - AppChannel.kt (193): secret · 뒤로 보내기 · 백그라운드 · 끝내기 · take… · 업데이트 설치 · 아이콘 · 작업 알림 - StorageChannel.kt (233): 전체 접근 · 저장소 목록 · 남은 공간 · 미디어 다시 읽기 · 주소 · 폴더 · 다른 앱으로 열기 · 공유 - SystemChannel.kt (221): Tor 웹 뷰 · Shizuku · 앱 관리자 · 망 종류 · 다운로드 프로그램 - OpenedFiles.kt (105): 받은 파일 읽기 · LaunchInbox - MainActivity 는 엔진 · 처리기 등록만. 채널 이름 · 메서드 이름 · 인자 그대로, flutter build apk --debug 통과 Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/jj/jj_mkvmaker/AppChannel.kt | 196 +++++ .../com/jj/jj_mkvmaker/ChannelModule.kt | 31 + .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 700 +----------------- .../kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt | 106 +++ .../com/jj/jj_mkvmaker/StorageChannel.kt | 235 ++++++ .../com/jj/jj_mkvmaker/SystemChannel.kt | 223 ++++++ 6 files changed, 807 insertions(+), 684 deletions(-) create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt new file mode 100644 index 0000000..b214ce8 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt @@ -0,0 +1,196 @@ +package com.jj.jj_mkvmaker + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 앱 자체 (비밀 보관 · 뒤로 보내기 · 백그라운드 · 끝내기 · 받은 파일 가져가기 · 업데이트 설치 · 아이콘 · 작업 알림) 채널 처리기. +/// 약속: +/// - 입력: 메서드 secretReadAll · secretWrite · secretDelete · moveToBack · setBackground · exitApp · takeOpenedFiles · takeShowJobs · +/// installApk · canInstallPackages · uninstallSelf · setAppIcon · keepAlive · notificationTexts · notifyDone · stopKeepAlive +/// 와 그 인자 (나누기 전과 같음). 켜질 때 받은 것은 [inbox] 에서 꺼낸다. +/// - 출력: 메서드마다 같은 답 (값 · null). 맡지 않은 메서드면 false. +/// - 오류: 비밀 보관 실패 "secret", 설치 허용이 꺼져 있으면 "PERMISSION", 그 밖의 설치 실패 "INSTALL". +class AppChannel(private val activity: Activity, private val inbox: LaunchInbox) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + "secretReadAll" -> { + try { + result.success(SecretBox.readAll(applicationContext)) + } catch (e: Exception) { + result.error("secret", e.toString(), null) + } + } + "secretWrite" -> { + try { + SecretBox.write(applicationContext, call.argument("key")!!, call.argument("value")!!) + result.success(null) + } catch (e: Exception) { + result.error("secret", e.toString(), null) + } + } + "secretDelete" -> { + SecretBox.delete(applicationContext, call.argument("key")!!) + result.success(null) + } + "moveToBack" -> { + // 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속) + moveTaskToBack(true) + result.success(null) + } + "setBackground" -> { + MainActivity.background = call.argument("on") ?: false + result.success(null) + } + "exitApp" -> { + // [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다 + result.success(null) + MainActivity.background = false + stopService(Intent(applicationContext, KeepAliveService::class.java)) + FlutterEngineCache.getInstance().remove(MainActivity.ENGINE_ID) + finishAndRemoveTask() + android.os.Handler(mainLooper).postDelayed({ + android.os.Process.killProcess(android.os.Process.myPid()) + }, 300) + } + "takeOpenedFiles" -> { + result.success(inbox.openFiles) + inbox.openFiles = emptyList() + } + "takeShowJobs" -> { + result.success(inbox.showJobs) + inbox.showJobs = false + } + "installApk" -> try { + installApk(call.argument("path") ?: "") + result.success(null) + } catch (e: InstallPermissionNeededException) { + // 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다 + result.error("PERMISSION", e.message ?: e.toString(), null) + } catch (e: Exception) { + // 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다) + result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null) + } + // 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지 + "canInstallPackages" -> result.success( + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() + ) + // 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다 + "uninstallSelf" -> { + startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + result.success(null) + } + "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) + "keepAlive" -> { + keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, + call.argument("icon") ?: "") + result.success(null) + } + "notificationTexts" -> { + // 57: 알림 글 · 채널 이름 (앱 화면 언어) + val m = call.arguments as? Map<*, *> ?: emptyMap() + KeepAliveService.setTexts(applicationContext, + m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap()) + result.success(null) + } + "notifyDone" -> { + KeepAliveService.notifyDone(applicationContext, call.argument("title") ?: "", + call.argument("text") ?: "") + result.success(null) + } + "stopKeepAlive" -> { + stopService(Intent(applicationContext, KeepAliveService::class.java)) + result.success(null) + } + else -> return false + } + } + return true + } + + /// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다. + /// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다) + private fun Activity.installApk(path: String) { + val src = File(path) + if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { + startActivity( + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요") + } + // FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다 + val dir = File(cacheDir, "updates").apply { mkdirs() } + val apk = File(dir, "update.apk") + if (src.absolutePath != apk.absolutePath) { + apk.delete() + if (!src.renameTo(apk)) { + src.copyTo(apk, overwrite = true) + src.delete() + } + } + val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk) + startActivity( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + + /// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별) + class InstallPermissionNeededException(message: String) : Exception(message) + + /// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로) + private fun Activity.setAppIcon(id: String): Boolean { + val ids = listOf("yellow", "black", "film_jj", "film", "blue") + if (id !in ids) return false + val pm = packageManager + for (i in ids) { + val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } + val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + val cn = android.content.ComponentName(this, name) + if (pm.getComponentEnabledSetting(cn) != state) { + pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP) + } + } + return true + } + + /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) + private var askedNotifications = false + + private fun Activity.keepAlive(text: String, progress: Int, icon: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + askedNotifications = true + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) + } + if (KeepAliveService.running) { + KeepAliveService.update(applicationContext, text, progress, icon) + return + } + val i = Intent(applicationContext, KeepAliveService::class.java) + .putExtra(KeepAliveService.EXTRA_TEXT, text) + .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) + .putExtra(KeepAliveService.EXTRA_ICON, icon) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i) + else applicationContext.startService(i) + } catch (e: Exception) { + // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 + android.util.Log.w("jj_mkvmaker", "keepAlive", e) + } + } +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt new file mode 100644 index 0000000..df30536 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt @@ -0,0 +1,31 @@ +package com.jj.jj_mkvmaker + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/// "jj_mkvmaker/android" 채널을 기능마다 나눈 처리기 (MainActivity 는 등록만 한다). +/// 약속: +/// - 입력: Dart 의 메서드 호출 (메서드 이름 · 인자 - 이름 · 인자는 나누기 전과 같다). +/// - 출력: 맡은 메서드면 [result] 에 답하고 true, 아니면 아무것도 하지 않고 false (다음 처리기로). +/// - 오류: 메서드마다 정한 대로 result.error 또는 실패 값 (false · null) 으로 답한다 (던지지 않음). +interface ChannelModule { + fun handle(call: MethodCall, result: MethodChannel.Result): Boolean +} + +/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트 · 앱 목록 등) 은 메인 스레드 밖에서 하고, 답은 메인 스레드에서. +/// 실패하면 오류 코드 "TOOLS" (예외 이름: 맨 안쪽 원인) +fun inBackground(result: MethodChannel.Result, work: () -> Any?) { + val main = Handler(Looper.getMainLooper()) + Thread { + try { + val value = work() + main.post { result.success(value) } + } catch (e: Throwable) { + android.util.Log.e("jj_mkvmaker", "download tools", e) + val cause = generateSequence(e) { it.cause }.last() + main.post { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) } + } + }.start() +} 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 fac88ac..380a60b 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 @@ -1,32 +1,21 @@ package com.jj.jj_mkvmaker -import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.webkit.ProfileStore -import androidx.webkit.WebViewFeature -import com.yausername.aria2c.Aria2c -import com.yausername.ffmpeg.FFmpeg -import com.yausername.youtubedl_android.YoutubeDL import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.plugin.common.MethodChannel -import java.io.File -/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비 +/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비. +/// "jj_mkvmaker/android" 채널은 기능마다 나눈 처리기 ([AppChannel] · [StorageChannel] · [SystemChannel]) 를 등록만 한다. class MainActivity : FlutterActivity() { companion object { /// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다 const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS" - private const val ENGINE_ID = "main" + /// 살려 두는 엔진의 이름 ([AppChannel] 의 exitApp 도 지운다) + internal const val ENGINE_ID = "main" /// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기) /// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠). @@ -63,233 +52,31 @@ class MainActivity : FlutterActivity() { private var channel: MethodChannel? = null - /// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다) - private var pendingShowJobs = false - - /// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다) - private var pendingOpen: List> = emptyList() + /// 앱이 켜질 때 받은 것 (작업 알림으로 열렸는지 · 다른 앱에서 연 동영상: Dart 가 준비되면 가져간다) + private val inbox = LaunchInbox() + private val opened by lazy { OpenedFiles(this) } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - pendingOpen = openedFiles(intent) - pendingShowJobs = intent?.action == ACTION_SHOW_JOBS + inbox.openFiles = opened.of(intent) + inbox.showJobs = intent?.action == ACTION_SHOW_JOBS val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") channel = ch // 88: SMB (smbj) - 탐색기의 SMB 서버 탭 SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger) if (reusedEngine) { // Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다 - if (pendingShowJobs) ch.invokeMethod("showJobs", null) - if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen) - pendingShowJobs = false - pendingOpen = emptyList() + if (inbox.showJobs) ch.invokeMethod("showJobs", null) + if (inbox.openFiles.isNotEmpty()) ch.invokeMethod("openFiles", inbox.openFiles) + inbox.showJobs = false + inbox.openFiles = emptyList() } + val modules = listOf(AppChannel(this, inbox), StorageChannel(this), SystemChannel(this)) ch.setMethodCallHandler { call, result -> - when (call.method) { - "secretReadAll" -> { - try { - result.success(SecretBox.readAll(applicationContext)) - } catch (e: Exception) { - result.error("secret", e.toString(), null) - } - } - "secretWrite" -> { - try { - SecretBox.write(applicationContext, call.argument("key")!!, call.argument("value")!!) - result.success(null) - } catch (e: Exception) { - result.error("secret", e.toString(), null) - } - } - "secretDelete" -> { - SecretBox.delete(applicationContext, call.argument("key")!!) - result.success(null) - } - "moveToBack" -> { - // 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속) - moveTaskToBack(true) - result.success(null) - } - "setBackground" -> { - background = call.argument("on") ?: false - result.success(null) - } - "exitApp" -> { - // [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다 - result.success(null) - background = false - stopService(Intent(applicationContext, KeepAliveService::class.java)) - FlutterEngineCache.getInstance().remove(ENGINE_ID) - finishAndRemoveTask() - android.os.Handler(mainLooper).postDelayed({ - android.os.Process.killProcess(android.os.Process.myPid()) - }, 300) - } - "takeOpenedFiles" -> { - result.success(pendingOpen) - pendingOpen = emptyList() - } - "takeShowJobs" -> { - result.success(pendingShowJobs) - pendingShowJobs = false - } - "hasAllFilesAccess" -> result.success(hasAllFilesAccess()) - "requestAllFilesAccess" -> { - requestAllFilesAccess() - result.success(null) - } - "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) - // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 - "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) - // Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음) - "torWebSupport" -> result.success(mapOf( - "proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE), - "multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE), - "documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), - )) - // Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시) - "torProfileClear" -> result.success(clearWebProfile(call.argument("name") ?: "jj_tor")) - "storageVolumes" -> result.success(storageVolumes()) - "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) - "installApk" -> try { - installApk(call.argument("path") ?: "") - result.success(null) - } catch (e: InstallPermissionNeededException) { - // 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다 - result.error("PERMISSION", e.message ?: e.toString(), null) - } catch (e: Exception) { - // 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다) - result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null) - } - // 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지 - "canInstallPackages" -> result.success( - Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() - ) - "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) - // X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐) - "shizukuStatus" -> result.success(ShizukuBridge.status()) - "shizukuRequest" -> shizukuRequest(result) - "shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument("path") ?: "") } - "shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument("path") ?: "") } - "shizukuCopy" -> inBackground(result) { - ShizukuBridge.copyTo(call.argument("path") ?: "", call.argument("dest") ?: "") - } - // 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() - if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null) - true - } catch (e: Exception) { - false - }) - // 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다 - "uninstallSelf" -> { - startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - result.success(null) - } - // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) - "netKinds" -> result.success(netKinds()) - // X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi). - // VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제 - "activeMetered" -> result.success(try { - (getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered - } catch (e: Exception) { - true - }) - "diskSpace" -> result.success( - try { - val st = android.os.StatFs(call.argument("path") ?: "") - listOf(st.availableBytes, st.totalBytes) - } catch (e: Exception) { - null - } - ) - "openWith" -> result.success( - openWith(call.argument("path") ?: "", call.argument("choose") ?: false) - ) - "shareFiles" -> result.success(shareFiles(call.argument>("paths") ?: emptyList())) - "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) - "keepAlive" -> { - keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, - call.argument("icon") ?: "") - result.success(null) - } - "notificationTexts" -> { - // 57: 알림 글 · 채널 이름 (앱 화면 언어) - val m = call.arguments as? Map<*, *> ?: emptyMap() - KeepAliveService.setTexts(applicationContext, - m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap()) - result.success(null) - } - "notifyDone" -> { - KeepAliveService.notifyDone(applicationContext, call.argument("title") ?: "", - call.argument("text") ?: "") - result.success(null) - } - "stopKeepAlive" -> { - stopService(Intent(applicationContext, KeepAliveService::class.java)) - result.success(null) - } - "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } - "downloadToolsUpdate" -> inBackground(result) { - YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: "" - } - else -> result.notImplemented() - } + if (modules.none { it.handle(call, result) }) result.notImplemented() } } - /// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다. - /// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다) - private fun installApk(path: String) { - val src = File(path) - if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { - startActivity( - Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요") - } - // FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다 - val dir = File(cacheDir, "updates").apply { mkdirs() } - val apk = File(dir, "update.apk") - if (src.absolutePath != apk.absolutePath) { - apk.delete() - if (!src.renameTo(apk)) { - src.copyTo(apk, overwrite = true) - src.delete() - } - } - val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk) - startActivity( - Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, "application/vnd.android.package-archive") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - - /// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별) - class InstallPermissionNeededException(message: String) : Exception(message) - /// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) @@ -297,462 +84,7 @@ class MainActivity : FlutterActivity() { channel?.invokeMethod("showJobs", null) return } - val files = openedFiles(intent) + val files = opened.of(intent) if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files) } - - /// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}] - private fun openedFiles(intent: Intent?): List> { - if (intent == null) return emptyList() - val uris = mutableListOf() - when (intent.action) { - Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } - Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) } - Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) } - } - return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) } - } - - @Suppress("DEPRECATION") - private fun intentStream(i: Intent): Uri? = - if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) - else i.getParcelableExtra(Intent.EXTRA_STREAM) - - @Suppress("DEPRECATION") - private fun intentStreams(i: Intent): List? = - if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) - else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM) - - private fun displayName(uri: Uri): String? = try { - contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { - if (it.moveToFirst()) it.getString(0) else null - } - } catch (e: Exception) { - null - } ?: uri.lastPathSegment?.substringAfterLast('/') - - /// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null. - private fun realPath(uri: Uri): String? { - fun ok(path: String?) = path != null && File(path).isFile - if (uri.scheme == "file") return uri.path?.takeIf { ok(it) } - if (uri.scheme != "content") return null - // 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4) - try { - if (android.provider.DocumentsContract.isDocumentUri(this, uri) && - uri.authority == "com.android.externalstorage.documents" - ) { - val id = android.provider.DocumentsContract.getDocumentId(uri) - val vol = id.substringBefore(':') - val rel = id.substringAfter(':', "") - val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol" - val path = "$base/$rel" - if (ok(path)) return path - } - } catch (e: Exception) { - } - // 2. 미디어 저장소 등이 알려 주는 경로 (_data) - try { - contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { - if (it.moveToFirst()) { - val path = it.getString(0) - if (ok(path)) return path - } - } - } catch (e: Exception) { - } - // 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서) - // 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만) - val size = try { - contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { - if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L - } ?: -1L - } catch (e: Exception) { - -1L - } - val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() } - val roots = storageVolumes().map { it["path"] as String } - for (i in segs.indices) { - val rest = segs.drop(i).joinToString("/") - for (root in roots) { - val f = File(root, rest) - if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath - } - // 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…) - val abs = "/$rest" - if (abs.startsWith("/storage/") && ok(abs)) return abs - } - return null - } - - /// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] - private fun storageVolumes(): List> { - val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager - val out = mutableListOf>() - for (v in sm.storageVolumes) { - if (v.state != Environment.MEDIA_MOUNTED) continue - val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - v.directory - } else { - try { - v.javaClass.getMethod("getPathFile").invoke(v) as File? - } catch (e: Exception) { - null - } - } - if (dir == null) continue - out.add( - mapOf( - "path" to dir.absolutePath, - "label" to (v.getDescription(this) ?: dir.name), - "removable" to v.isRemovable, - "primary" to v.isPrimary, - ) - ) - } - return out - } - - /// 웹 주소를 기기의 기본 브라우저로 - private fun openUrl(url: String): Boolean = try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - true - } catch (e: Exception) { - false - } - - /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. - /// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로) - private fun setAppIcon(id: String): Boolean { - val ids = listOf("yellow", "black", "film_jj", "film", "blue") - if (id !in ids) return false - val pm = packageManager - for (i in ids) { - val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } - val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED - else PackageManager.COMPONENT_ENABLED_STATE_DISABLED - val cn = android.content.ComponentName(this, name) - if (pm.getComponentEnabledSetting(cn) != state) { - pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP) - } - } - return true - } - - /// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창). - /// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml). - private fun openWith(path: String, choose: Boolean): Boolean { - val f = File(path) - if (!f.isFile) return false - val uri = try { - androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) - } catch (e: Exception) { - return false - } - val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*" - val view = Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, mime) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - fun chooser(): Intent = Intent.createChooser(view, null) - .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return try { - startActivity(if (choose) chooser() else view) - true - } catch (e: Exception) { - try { - startActivity(chooser()) - true - } catch (e2: Exception) { - false - } - } - } - - /// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌) - private fun shareFiles(paths: List): Boolean { - val uris = ArrayList() - val mimes = mutableSetOf() - for (p in paths) { - val f = File(p) - if (!f.isFile) continue - uris.add( - try { - androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) - } catch (e: Exception) { - return false - } - ) - mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*") - } - if (uris.isEmpty()) return false - // 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */* - val tops = mimes.map { it.substringBefore('/') }.toSet() - val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*" - val send = if (uris.size == 1) { - Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) - } else { - Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) - } - send.type = mime - // 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다) - val clip = android.content.ClipData.newRawUri(null, uris[0]) - for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i])) - send.clipData = clip - send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - return try { - startActivity( - Intent.createChooser(send, null) - .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - true - } catch (e: Exception) { - false - } - } - - private fun openFolder(path: String): Boolean { - // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") - val root = Environment.getExternalStorageDirectory().path - val (volumeId, rel) = when { - path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/') - path.startsWith("/storage/") -> { - val rest = path.removePrefix("/storage/") - rest.substringBefore('/') to rest.substringAfter('/', "").trim('/') - } - else -> return false - } - // 1. 삼성 "내 파일": 그 폴더로 바로 연다 - try { - startActivity( - Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES") - .setPackage("com.sec.android.app.myfiles") - .putExtra("samsung.myfiles.intent.extra.START_PATH", path) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - return true - } catch (e: Exception) { - } - // 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다 - val uri = android.provider.DocumentsContract.buildDocumentUri( - "com.android.externalstorage.documents", "$volumeId:$rel" - ) - for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) { - try { - startActivity( - Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR) - .apply { if (pkg != null) setPackage(pkg) } - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) - ) - return true - } catch (e: Exception) { - } - } - return false - } - - /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) - private var askedNotifications = false - - private fun keepAlive(text: String, progress: Int, icon: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - askedNotifications = true - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) - } - if (KeepAliveService.running) { - KeepAliveService.update(applicationContext, text, progress, icon) - return - } - val i = Intent(applicationContext, KeepAliveService::class.java) - .putExtra(KeepAliveService.EXTRA_TEXT, text) - .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) - .putExtra(KeepAliveService.EXTRA_ICON, icon) - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i) - else applicationContext.startService(i) - } catch (e: Exception) { - // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 - android.util.Log.w("jj_mkvmaker", "keepAlive", e) - } - } - - /// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · 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 - } - - /// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다 - private var shizukuPending: MethodChannel.Result? = null - private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant -> - if (code == 5202) { - shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED) - shizukuPending = null - } - } - - private fun shizukuRequest(result: MethodChannel.Result) { - try { - if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false) - if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true) - shizukuPending?.success(false) - shizukuPending = result - rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener) - rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener) - rikka.shizuku.Shizuku.requestPermission(5202) - } catch (e: Throwable) { - shizukuPending = null - result.success(false) - } - } - - private fun inBackground(result: MethodChannel.Result, work: () -> Any?) { - Thread { - try { - val value = work() - runOnUiThread { result.success(value) } - } catch (e: Throwable) { - android.util.Log.e("jj_mkvmaker", "download tools", e) - val cause = generateSequence(e) { it.cause }.last() - runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) } - } - }.start() - } - - /// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다. - /// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다. - private fun downloadToolsInit(): Map { - YoutubeDL.init(applicationContext) - FFmpeg.init(applicationContext) - Aria2c.init(applicationContext) - val bin = applicationInfo.nativeLibraryDir - val base = File(noBackupFilesDir, YoutubeDL.baseName) - val packages = File(base, "packages") - val python = File(packages, "python").absolutePath - val ffmpeg = File(packages, "ffmpeg").absolutePath - val aria2c = File(packages, "aria2c").absolutePath - val cert = "$python/usr/etc/tls/cert.pem" - return mapOf( - "python" to "$bin/libpython.so", - "ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath, - "ffmpeg" to "$bin/libffmpeg.so", - "quickjs" to "$bin/libqjs.so", - "aria2c" to "$bin/libaria2c.so", - "cert" to cert, - "version" to (YoutubeDL.versionName(applicationContext) ?: ""), - "env" to mapOf( - "LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib", - "SSL_CERT_FILE" to cert, - "PATH" to (System.getenv("PATH") ?: "") + ":" + bin, - "PYTHONHOME" to "$python/usr", - "HOME" to "$python/usr", - "TMPDIR" to cacheDir.absolutePath, - ), - ) - } - - /// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false - private fun clearWebProfile(name: String): Boolean { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true - val store = ProfileStore.getInstance() - if (store.getProfile(name) == null) return true - return try { - store.deleteProfile(name) - } catch (e: IllegalStateException) { - try { - val profile = store.getProfile(name) - profile?.cookieManager?.removeAllCookies(null) - profile?.webStorage?.deleteAllData() - } catch (_: Exception) { - } - false - } - } - - private fun hasAllFilesAccess(): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - } - - private fun requestAllFilesAccess() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))) - } catch (e: Exception) { - startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) - } - } else { - requestPermissions( - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1 - ) - } - } - - /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) - @Suppress("DEPRECATION") - private fun netKinds(): Map? = try { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager - val out = HashMap() - for (n in cm.allNetworks) { - val caps = cm.getNetworkCapabilities(n) ?: continue - val lp = cm.getLinkProperties(n) ?: continue - val kind = when { - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" - else -> "other" - } - for (la in lp.linkAddresses) { - val a = la.address - if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } - } - } - out - } catch (e: Exception) { - null - } } diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt new file mode 100644 index 0000000..167ce42 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt @@ -0,0 +1,106 @@ +package com.jj.jj_mkvmaker + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import java.io.File + +/// 앱이 켜질 때 받은 것 (Dart 가 준비되면 "takeOpenedFiles" · "takeShowJobs" 로 가져간다) +class LaunchInbox { + /// 다른 앱에서 연 동영상 + var openFiles: List> = emptyList() + + /// 작업 알림으로 열렸는지 + var showJobs = false +} + +/// 연결 프로그램 (VIEW) · 공유 (SEND · SEND_MULTIPLE) 로 받은 파일을 읽는 모듈. +/// 약속: +/// - 입력: 받은 [Intent] (null 이면 없음), 쓸 수 있는 저장소 목록 ([storageVolumes] - 경로 찾기에 씀). +/// - 출력: [{path (실제 경로, 모르면 null), uri, name}] 목록. 그 밖의 action 이면 빈 목록. +/// - 오류: 던지지 않는다. 경로 · 이름을 읽지 못하면 null. +class OpenedFiles(private val context: Context) { + fun of(intent: Intent?): List> { + if (intent == null) return emptyList() + val uris = mutableListOf() + when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } + Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) } + Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) } + } + return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) } + } + + @Suppress("DEPRECATION") + private fun intentStream(i: Intent): Uri? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableExtra(Intent.EXTRA_STREAM) + + @Suppress("DEPRECATION") + private fun intentStreams(i: Intent): List? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + + private fun displayName(uri: Uri): String? = try { + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (e: Exception) { + null + } ?: uri.lastPathSegment?.substringAfterLast('/') + + /// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null. + private fun realPath(uri: Uri): String? { + fun ok(path: String?) = path != null && File(path).isFile + if (uri.scheme == "file") return uri.path?.takeIf { ok(it) } + if (uri.scheme != "content") return null + // 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4) + try { + if (android.provider.DocumentsContract.isDocumentUri(context, uri) && + uri.authority == "com.android.externalstorage.documents" + ) { + val id = android.provider.DocumentsContract.getDocumentId(uri) + val vol = id.substringBefore(':') + val rel = id.substringAfter(':', "") + val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol" + val path = "$base/$rel" + if (ok(path)) return path + } + } catch (e: Exception) { + } + // 2. 미디어 저장소 등이 알려 주는 경로 (_data) + try { + context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { + if (it.moveToFirst()) { + val path = it.getString(0) + if (ok(path)) return path + } + } + } catch (e: Exception) { + } + // 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서) + // 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만) + val size = try { + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { + if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L + } ?: -1L + } catch (e: Exception) { + -1L + } + val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() } + val roots = context.storageVolumes().map { it["path"] as String } + for (i in segs.indices) { + val rest = segs.drop(i).joinToString("/") + for (root in roots) { + val f = File(root, rest) + if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath + } + // 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…) + val abs = "/$rest" + if (abs.startsWith("/storage/") && ok(abs)) return abs + } + return null + } +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt new file mode 100644 index 0000000..2410d92 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt @@ -0,0 +1,235 @@ +package com.jj.jj_mkvmaker + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 저장소 · 파일 (전체 접근 · 저장소 목록 · 남은 공간 · 미디어 다시 읽기 · 주소 · 폴더 열기 · 다른 앱으로 열기 · 공유) 채널 처리기. +/// 약속: +/// - 입력: 메서드 hasAllFilesAccess · requestAllFilesAccess · storageRoot · nativeLibDir · storageVolumes · openUrl · openFolder · +/// mediaScan · diskSpace · openWith · shareFiles 와 그 인자 (나누기 전과 같음). +/// - 출력: 메서드마다 같은 답 (참 · 거짓 · 경로 · 목록 · [남은, 전체] 바이트). 맡지 않은 메서드면 false. +/// - 오류: 던지지 않는다. 열 앱이 없거나 읽지 못하면 false · null. +class StorageChannel(private val activity: Activity) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + "hasAllFilesAccess" -> result.success(hasAllFilesAccess()) + "requestAllFilesAccess" -> { + requestAllFilesAccess() + result.success(null) + } + "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 + "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) + "storageVolumes" -> result.success(storageVolumes()) + "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) + "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) + // X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히) + "mediaScan" -> result.success(try { + val paths = call.argument>("paths") ?: emptyList() + if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null) + true + } catch (e: Exception) { + false + }) + "diskSpace" -> result.success( + try { + val st = android.os.StatFs(call.argument("path") ?: "") + listOf(st.availableBytes, st.totalBytes) + } catch (e: Exception) { + null + } + ) + "openWith" -> result.success( + openWith(call.argument("path") ?: "", call.argument("choose") ?: false) + ) + "shareFiles" -> result.success(shareFiles(call.argument>("paths") ?: emptyList())) + else -> return false + } + } + return true + } + + /// 웹 주소를 기기의 기본 브라우저로 + private fun Activity.openUrl(url: String): Boolean = try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + + /// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창). + /// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml). + private fun Activity.openWith(path: String, choose: Boolean): Boolean { + val f = File(path) + if (!f.isFile) return false + val uri = try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*" + val view = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + fun chooser(): Intent = Intent.createChooser(view, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + startActivity(if (choose) chooser() else view) + true + } catch (e: Exception) { + try { + startActivity(chooser()) + true + } catch (e2: Exception) { + false + } + } + } + + /// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌) + private fun Activity.shareFiles(paths: List): Boolean { + val uris = ArrayList() + val mimes = mutableSetOf() + for (p in paths) { + val f = File(p) + if (!f.isFile) continue + uris.add( + try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + ) + mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*") + } + if (uris.isEmpty()) return false + // 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */* + val tops = mimes.map { it.substringBefore('/') }.toSet() + val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*" + val send = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + } + send.type = mime + // 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다) + val clip = android.content.ClipData.newRawUri(null, uris[0]) + for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i])) + send.clipData = clip + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity( + Intent.createChooser(send, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (e: Exception) { + false + } + } + + /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. + private fun Activity.openFolder(path: String): Boolean { + // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") + val root = Environment.getExternalStorageDirectory().path + val (volumeId, rel) = when { + path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/') + path.startsWith("/storage/") -> { + val rest = path.removePrefix("/storage/") + rest.substringBefore('/') to rest.substringAfter('/', "").trim('/') + } + else -> return false + } + // 1. 삼성 "내 파일": 그 폴더로 바로 연다 + try { + startActivity( + Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES") + .setPackage("com.sec.android.app.myfiles") + .putExtra("samsung.myfiles.intent.extra.START_PATH", path) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + return true + } catch (e: Exception) { + } + // 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다 + val uri = android.provider.DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "$volumeId:$rel" + ) + for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) { + try { + startActivity( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR) + .apply { if (pkg != null) setPackage(pkg) } + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + return true + } catch (e: Exception) { + } + } + return false + } + + private fun Activity.hasAllFilesAccess(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } + + private fun Activity.requestAllFilesAccess() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))) + } catch (e: Exception) { + startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) + } + } else { + requestPermissions( + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1 + ) + } + } +} + +/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] +fun Context.storageVolumes(): List> { + val sm = getSystemService(Context.STORAGE_SERVICE) as android.os.storage.StorageManager + val out = mutableListOf>() + for (v in sm.storageVolumes) { + if (v.state != Environment.MEDIA_MOUNTED) continue + val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + v.directory + } else { + try { + v.javaClass.getMethod("getPathFile").invoke(v) as File? + } catch (e: Exception) { + null + } + } + if (dir == null) continue + out.add( + mapOf( + "path" to dir.absolutePath, + "label" to (v.getDescription(this) ?: dir.name), + "removable" to v.isRemovable, + "primary" to v.isPrimary, + ) + ) + } + return out +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt new file mode 100644 index 0000000..431e6d4 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt @@ -0,0 +1,223 @@ +package com.jj.jj_mkvmaker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.webkit.ProfileStore +import androidx.webkit.WebViewFeature +import com.yausername.aria2c.Aria2c +import com.yausername.ffmpeg.FFmpeg +import com.yausername.youtubedl_android.YoutubeDL +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 기기 · 시스템 (Tor 웹 뷰 · Shizuku 시스템 폴더 · 앱 관리자 · 망 종류 · 다운로드 프로그램) 채널 처리기. +/// 약속: +/// - 입력: 메서드 torWebSupport · torProfileClear · shizukuStatus · shizukuRequest · shizukuList · shizukuStat · shizukuCopy · +/// launcherApps · appIcon · appDetails · uninstallApp · netKinds · activeMetered · downloadToolsInit · downloadToolsUpdate +/// 와 그 인자 (나누기 전과 같음). +/// - 출력: 메서드마다 같은 답 (지도 · 목록 · PNG 바이트 · 참 · 거짓). 오래 걸리는 것은 [inBackground] 로. 맡지 않은 메서드면 false. +/// - 오류: 뒤에서 하는 일이 실패하면 "TOOLS", 그 밖에는 false · null (Shizuku 허락 실패는 false). +class SystemChannel(private val activity: Activity) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + // Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음) + "torWebSupport" -> result.success(mapOf( + "proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE), + "multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE), + "documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), + )) + // Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시) + "torProfileClear" -> result.success(clearWebProfile(call.argument("name") ?: "jj_tor")) + // X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐) + "shizukuStatus" -> result.success(ShizukuBridge.status()) + "shizukuRequest" -> shizukuRequest(result) + "shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument("path") ?: "") } + "shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument("path") ?: "") } + "shizukuCopy" -> inBackground(result) { + ShizukuBridge.copyTo(call.argument("path") ?: "", call.argument("dest") ?: "") + } + // 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 + }) + // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) + "netKinds" -> result.success(netKinds()) + // X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi). + // VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제 + "activeMetered" -> result.success(try { + (getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered + } catch (e: Exception) { + true + }) + "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } + "downloadToolsUpdate" -> inBackground(result) { + YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: "" + } + else -> return false + } + } + return true + } + + /// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지) + private fun Activity.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 Activity.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 + } + + /// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다 + private var shizukuPending: MethodChannel.Result? = null + private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant -> + if (code == 5202) { + shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED) + shizukuPending = null + } + } + + private fun Activity.shizukuRequest(result: MethodChannel.Result) { + try { + if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false) + if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true) + shizukuPending?.success(false) + shizukuPending = result + rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener) + rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener) + rikka.shizuku.Shizuku.requestPermission(5202) + } catch (e: Throwable) { + shizukuPending = null + result.success(false) + } + } + + /// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다. + /// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다. + private fun Activity.downloadToolsInit(): Map { + YoutubeDL.init(applicationContext) + FFmpeg.init(applicationContext) + Aria2c.init(applicationContext) + val bin = applicationInfo.nativeLibraryDir + val base = File(noBackupFilesDir, YoutubeDL.baseName) + val packages = File(base, "packages") + val python = File(packages, "python").absolutePath + val ffmpeg = File(packages, "ffmpeg").absolutePath + val aria2c = File(packages, "aria2c").absolutePath + val cert = "$python/usr/etc/tls/cert.pem" + return mapOf( + "python" to "$bin/libpython.so", + "ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath, + "ffmpeg" to "$bin/libffmpeg.so", + "quickjs" to "$bin/libqjs.so", + "aria2c" to "$bin/libaria2c.so", + "cert" to cert, + "version" to (YoutubeDL.versionName(applicationContext) ?: ""), + "env" to mapOf( + "LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib", + "SSL_CERT_FILE" to cert, + "PATH" to (System.getenv("PATH") ?: "") + ":" + bin, + "PYTHONHOME" to "$python/usr", + "HOME" to "$python/usr", + "TMPDIR" to cacheDir.absolutePath, + ), + ) + } + + /// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false + private fun Activity.clearWebProfile(name: String): Boolean { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true + val store = ProfileStore.getInstance() + if (store.getProfile(name) == null) return true + return try { + store.deleteProfile(name) + } catch (e: IllegalStateException) { + try { + val profile = store.getProfile(name) + profile?.cookieManager?.removeAllCookies(null) + profile?.webStorage?.deleteAllData() + } catch (_: Exception) { + } + false + } + } + + /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) + @Suppress("DEPRECATION") + private fun Activity.netKinds(): Map? = try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + val out = HashMap() + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + val lp = cm.getLinkProperties(n) ?: continue + val kind = when { + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + for (la in lp.linkAddresses) { + val a = la.address + if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } + } + } + out + } catch (e: Exception) { + null + } +}