나누기: MainActivity.kt (758) 의 채널을 기능별 처리기 모듈로 (→ 86 + 5개)
- 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 <noreply@anthropic.com>split_ui
parent
f183d5866f
commit
c75317e947
|
|
@ -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<String>("key")!!, call.argument<String>("value")!!)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("secret", e.toString(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"secretDelete" -> {
|
||||||
|
SecretBox.delete(applicationContext, call.argument<String>("key")!!)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"moveToBack" -> {
|
||||||
|
// 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속)
|
||||||
|
moveTaskToBack(true)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setBackground" -> {
|
||||||
|
MainActivity.background = call.argument<Boolean>("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<String>("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<String>("id") ?: ""))
|
||||||
|
"keepAlive" -> {
|
||||||
|
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
|
||||||
|
call.argument<String>("icon") ?: "")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"notificationTexts" -> {
|
||||||
|
// 57: 알림 글 · 채널 이름 (앱 화면 언어)
|
||||||
|
val m = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||||
|
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<String>("title") ?: "",
|
||||||
|
call.argument<String>("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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,21 @@
|
||||||
package com.jj.jj_mkvmaker
|
package com.jj.jj_mkvmaker
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.FlutterEngineCache
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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() {
|
class MainActivity : FlutterActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
|
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
|
||||||
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
|
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 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기)
|
||||||
/// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
|
/// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
|
||||||
|
|
@ -63,232 +52,30 @@ class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
private var channel: MethodChannel? = null
|
private var channel: MethodChannel? = null
|
||||||
|
|
||||||
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
|
/// 앱이 켜질 때 받은 것 (작업 알림으로 열렸는지 · 다른 앱에서 연 동영상: Dart 가 준비되면 가져간다)
|
||||||
private var pendingShowJobs = false
|
private val inbox = LaunchInbox()
|
||||||
|
private val opened by lazy { OpenedFiles(this) }
|
||||||
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
|
|
||||||
private var pendingOpen: List<Map<String, String?>> = emptyList()
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
pendingOpen = openedFiles(intent)
|
inbox.openFiles = opened.of(intent)
|
||||||
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
|
inbox.showJobs = intent?.action == ACTION_SHOW_JOBS
|
||||||
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
|
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
|
||||||
channel = ch
|
channel = ch
|
||||||
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭
|
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭
|
||||||
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
|
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
|
||||||
if (reusedEngine) {
|
if (reusedEngine) {
|
||||||
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
|
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
|
||||||
if (pendingShowJobs) ch.invokeMethod("showJobs", null)
|
if (inbox.showJobs) ch.invokeMethod("showJobs", null)
|
||||||
if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen)
|
if (inbox.openFiles.isNotEmpty()) ch.invokeMethod("openFiles", inbox.openFiles)
|
||||||
pendingShowJobs = false
|
inbox.showJobs = false
|
||||||
pendingOpen = emptyList()
|
inbox.openFiles = emptyList()
|
||||||
}
|
}
|
||||||
|
val modules = listOf(AppChannel(this, inbox), StorageChannel(this), SystemChannel(this))
|
||||||
ch.setMethodCallHandler { call, result ->
|
ch.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
if (modules.none { it.handle(call, result) }) result.notImplemented()
|
||||||
"secretReadAll" -> {
|
|
||||||
try {
|
|
||||||
result.success(SecretBox.readAll(applicationContext))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("secret", e.toString(), null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"secretWrite" -> {
|
|
||||||
try {
|
|
||||||
SecretBox.write(applicationContext, call.argument<String>("key")!!, call.argument<String>("value")!!)
|
|
||||||
result.success(null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("secret", e.toString(), null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"secretDelete" -> {
|
|
||||||
SecretBox.delete(applicationContext, call.argument<String>("key")!!)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"moveToBack" -> {
|
|
||||||
// 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속)
|
|
||||||
moveTaskToBack(true)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"setBackground" -> {
|
|
||||||
background = call.argument<Boolean>("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<String>("name") ?: "jj_tor"))
|
|
||||||
"storageVolumes" -> result.success(storageVolumes())
|
|
||||||
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
|
|
||||||
"installApk" -> try {
|
|
||||||
installApk(call.argument<String>("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<String>("path") ?: ""))
|
|
||||||
// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐)
|
|
||||||
"shizukuStatus" -> result.success(ShizukuBridge.status())
|
|
||||||
"shizukuRequest" -> shizukuRequest(result)
|
|
||||||
"shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument<String>("path") ?: "") }
|
|
||||||
"shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument<String>("path") ?: "") }
|
|
||||||
"shizukuCopy" -> inBackground(result) {
|
|
||||||
ShizukuBridge.copyTo(call.argument<String>("path") ?: "", call.argument<String>("dest") ?: "")
|
|
||||||
}
|
|
||||||
// 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 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
|
|
||||||
"mediaScan" -> result.success(try {
|
|
||||||
val paths = call.argument<List<String>>("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<String>("path") ?: "")
|
|
||||||
listOf(st.availableBytes, st.totalBytes)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"openWith" -> result.success(
|
|
||||||
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
|
|
||||||
)
|
|
||||||
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
|
|
||||||
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
|
|
||||||
"keepAlive" -> {
|
|
||||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
|
|
||||||
call.argument<String>("icon") ?: "")
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
"notificationTexts" -> {
|
|
||||||
// 57: 알림 글 · 채널 이름 (앱 화면 언어)
|
|
||||||
val m = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
|
||||||
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<String>("title") ?: "",
|
|
||||||
call.argument<String>("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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 받은 업데이트 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)
|
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|
@ -297,462 +84,7 @@ class MainActivity : FlutterActivity() {
|
||||||
channel?.invokeMethod("showJobs", null)
|
channel?.invokeMethod("showJobs", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val files = openedFiles(intent)
|
val files = opened.of(intent)
|
||||||
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
|
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}]
|
|
||||||
private fun openedFiles(intent: Intent?): List<Map<String, String?>> {
|
|
||||||
if (intent == null) return emptyList()
|
|
||||||
val uris = mutableListOf<Uri>()
|
|
||||||
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<Uri>? =
|
|
||||||
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<Map<String, Any>> {
|
|
||||||
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager
|
|
||||||
val out = mutableListOf<Map<String, Any>>()
|
|
||||||
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<String>): Boolean {
|
|
||||||
val uris = ArrayList<android.net.Uri>()
|
|
||||||
val mimes = mutableSetOf<String>()
|
|
||||||
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<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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String, Any> {
|
|
||||||
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<String, String>? = try {
|
|
||||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
|
||||||
val out = HashMap<String, String>()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Map<String, String?>> = 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<Map<String, String?>> {
|
||||||
|
if (intent == null) return emptyList()
|
||||||
|
val uris = mutableListOf<Uri>()
|
||||||
|
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<Uri>? =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>("url") ?: ""))
|
||||||
|
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||||
|
// X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
|
||||||
|
"mediaScan" -> result.success(try {
|
||||||
|
val paths = call.argument<List<String>>("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<String>("path") ?: "")
|
||||||
|
listOf(st.availableBytes, st.totalBytes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"openWith" -> result.success(
|
||||||
|
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
|
||||||
|
)
|
||||||
|
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("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<String>): Boolean {
|
||||||
|
val uris = ArrayList<android.net.Uri>()
|
||||||
|
val mimes = mutableSetOf<String>()
|
||||||
|
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<Map<String, Any>> {
|
||||||
|
val sm = getSystemService(Context.STORAGE_SERVICE) as android.os.storage.StorageManager
|
||||||
|
val out = mutableListOf<Map<String, Any>>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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<String>("name") ?: "jj_tor"))
|
||||||
|
// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐)
|
||||||
|
"shizukuStatus" -> result.success(ShizukuBridge.status())
|
||||||
|
"shizukuRequest" -> shizukuRequest(result)
|
||||||
|
"shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument<String>("path") ?: "") }
|
||||||
|
"shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument<String>("path") ?: "") }
|
||||||
|
"shizukuCopy" -> inBackground(result) {
|
||||||
|
ShizukuBridge.copyTo(call.argument<String>("path") ?: "", call.argument<String>("dest") ?: "")
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
// 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<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 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<String, Any> {
|
||||||
|
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<String, String>? = try {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
val out = HashMap<String, String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue