Compare commits
No commits in common. "408cf3101addff9690de3e14349bea0121ed862c" and "main" have entirely different histories.
408cf3101a
...
main
|
|
@ -56,3 +56,16 @@ app.*.map.json
|
|||
/jj_aria2/
|
||||
*.log
|
||||
/android/local.properties
|
||||
|
||||
# 내장 Chrome: 빌드할 때 내려받는 CEF 배포본 (약 330MB)
|
||||
/packages/webview_cef/third/cef/
|
||||
|
||||
# Android 빌드 중간 파일
|
||||
/android/build/
|
||||
/packages/*/android/.cxx/
|
||||
/packages/*/android/build/
|
||||
|
||||
# 플러그인 사본 안에 IDE 가 만든 Gradle 캐시
|
||||
packages/*/android/.gradle/
|
||||
# 플러그인 사본에서 pub get 을 하면 생기는 잠금 파일 (앱의 pubspec.lock 이 버전을 정함)
|
||||
packages/*/pubspec.lock
|
||||
|
|
|
|||
14
README.md
|
|
@ -24,14 +24,17 @@
|
|||
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
|
||||
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
|
||||
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
|
||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
|
||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유, 다른 언어 페이지를 화면 언어로 자동 번역 (Google 번역), JavaScript 끄기 |
|
||||
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
||||
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
|
||||
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
|
||||
| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 |
|
||||
|
||||
## 실행
|
||||
|
||||
1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다.
|
||||
2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트)
|
||||
3. 폴더 전체가 프로그램입니다. exe 파일 하나만 옮기면 동작하지 않습니다.
|
||||
3. 폴더 전체가 프로그램입니다. 맨 위의 `jj_mkvmaker.exe` 는 `Lib\` 안의 프로그램을 실행하는 시작 프로그램이고, 작업 기록은 `Logs\` 에 남습니다. exe 파일 하나만 옮기면 동작하지 않습니다.
|
||||
|
||||
처음 쓸 때 내려받는 것 (한 번만):
|
||||
|
||||
|
|
@ -57,7 +60,12 @@ OpenSubtitles 의 무료 API 키가 필요합니다.
|
|||
| 플레이어 | Space 재생/정지 · ←/→ 10초 · Shift+←/→ 1분 · ↑/↓ 음량 · F 전체 화면 · Esc 나가기 · N/P 다음/이전 · M 음소거 · S 자막 · L 재생 목록 |
|
||||
| 자막 편집 | Ctrl+Space 재생/정지 · Alt+←/→ 1초 · Alt+Shift+←/→ 0.1초 · F7 새 줄 · F8 선택 줄 재생 · F9 시작=현재 · F10 끝=현재 |
|
||||
| 브라우저 | Ctrl+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 |
|
||||
| 어디서나 | Ctrl+Shift+X 창 보이기 (환경 설정에서 변경) |
|
||||
| 어디서나 | Ctrl+Shift+X 창 보이기 / 트레이로 숨기기 (환경 설정에서 변경) |
|
||||
|
||||
## 버전
|
||||
|
||||
`년.월.일_순번` (예: `2026.09.30_001`). 같은 날 다시 내면 순번이 늘고, 날이 바뀌면 `001` 부터 다시 시작합니다.
|
||||
번호 올리기: `powershell -File tool\next_version.ps1` (pubspec.yaml 에는 Flutter 형식 `2026.9.30+1` 로 적힙니다)
|
||||
|
||||
## 소스에서 빌드
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,22 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
|
|||
|
||||
위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
|
||||
|
||||
[Android 판 (APK) 에만 들어 있는 구성 요소]
|
||||
|
||||
- FFmpegKit (ffmpeg_kit_flutter_new_min_gpl, JJ_MKVMaker 수정본)
|
||||
GPL-3.0 (FFmpeg GPL 빌드 포함) https://pub.dev/packages/ffmpeg_kit_flutter_new_min_gpl
|
||||
수정 내용: app\packages\ffmpeg_kit_flutter_new_min_gpl\JJ_PATCH.md
|
||||
- youtubedl-android (Python · yt-dlp · FFmpeg · aria2c 실행 파일 포함)
|
||||
GPL-3.0 https://github.com/JunkFood02/youtubedl-android
|
||||
· Python PSF License https://www.python.org
|
||||
· QuickJS MIT https://bellard.org/quickjs/
|
||||
· yt-dlp Unlicense
|
||||
· FFmpeg GPL https://ffmpeg.org
|
||||
· aria2 GPL-2.0-or-later https://github.com/aria2/aria2
|
||||
수정하지 않은 공식 배포본 (Maven Central io.github.junkfood02.youtubedl-android 0.18.1) 이며,
|
||||
소스는 위 주소에서 받을 수 있습니다.
|
||||
- libmpv (media_kit_libs_android_video) LGPL-2.1+ https://github.com/media-kit/libmpv-android-video-build
|
||||
|
||||
[처음 사용할 때 내려받는 AI 모델]
|
||||
|
||||
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ analyzer:
|
|||
- build/**
|
||||
- android/**
|
||||
- windows/**
|
||||
- packages/webview_cef/**
|
||||
- packages/flutter_inappwebview_android/**
|
||||
- packages/ffmpeg_kit_flutter_new_min_gpl/** # 고친 외부 부품 (원본 코드 그대로)
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 배포 서명 키 (android/key.properties, 저장소에 올리지 않음). 없으면 디버그 키로 서명.
|
||||
val keyProps = Properties().apply {
|
||||
val f = rootProject.file("key.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jj.jj_mkvmaker"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
// whisper_ggml 이 NDK 29 를 요구 (더 낮은 버전용 코드와 호환)
|
||||
ndkVersion = "29.0.13113456"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
|
|
@ -15,25 +24,46 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.jj.jj_mkvmaker"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
// ffmpeg-kit 은 Android 7.0 (API 24) 이상
|
||||
minSdk = maxOf(24, flutter.minSdkVersion)
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
// 버전 "년.월.일+순번" 의 순번은 날마다 1 부터 다시 시작하므로 그대로 쓰면 다음 날 판이 "더 낮은 버전" 으로
|
||||
// 설치가 거부된다. 날짜를 앞에 붙여 늘 커지게 한다: 2026.10.2+1 → 261002001
|
||||
versionCode = flutter.versionName.split(".").let { (y, m, d) ->
|
||||
(y.toInt() % 100) * 10_000_000 + m.toInt() * 100_000 + d.toInt() * 1_000 + flutter.versionCode
|
||||
}
|
||||
versionName = flutter.versionName
|
||||
// 요즘 휴대폰 · 태블릿 (64비트 ARM) 만. 플러그인이 다른 CPU 용 라이브러리를 넣어도 앱 본체가 없으면 실행되지 않으므로 뺀다
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keyProps.getProperty("storeFile") != null) {
|
||||
create("release") {
|
||||
storeFile = file(keyProps.getProperty("storeFile"))
|
||||
storePassword = keyProps.getProperty("storePassword")
|
||||
keyAlias = keyProps.getProperty("keyAlias")
|
||||
keyPassword = keyProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드)
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
// 64비트 ARM 만 (위 abiFilters 와 같은 뜻, 플러그인 라이브러리까지 확실히 빼기)
|
||||
excludes += setOf("lib/x86_64/**", "lib/x86/**", "lib/armeabi-v7a/**")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,3 +77,13 @@ kotlin {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// 다운로드 (앱 안 브라우저 · 다운로드 목록): Android 용 yt-dlp (Python 포함) · ffmpeg (영상 · 음성 합치기) · aria2c (토렌트)
|
||||
// 실행 파일을 앱에 넣고 처음 켤 때 풀어 둔다. 실행은 Dart (YtDlpBackend · Aria2Backend) 가 Windows 와 같은 방식으로 한다.
|
||||
dependencies {
|
||||
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
|
||||
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
|
||||
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
|
||||
// 업데이트 APK 를 설치 화면에 넘기는 FileProvider
|
||||
implementation("androidx.core:core:1.16.0")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# youtubedl-android (Android 다운로드): 코드 축소(R8)가 Jackson · 압축 풀기 클래스를 지우거나 이름을 바꾸면
|
||||
# YoutubeDL 객체를 만들 때 ExceptionInInitializerError 가 난다 (youtubedl-android README 의 권장 규칙)
|
||||
-keep class com.yausername.** { *; }
|
||||
-keep class com.fasterxml.jackson.** { *; }
|
||||
-keep class org.apache.commons.compress.archivers.zip.** { *; }
|
||||
-dontwarn com.fasterxml.jackson.**
|
||||
-dontwarn org.apache.commons.**
|
||||
|
|
@ -4,4 +4,6 @@
|
|||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="jj_mkvmaker"
|
||||
android:label="JJ_MKVMaker"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
|
||||
<property
|
||||
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
|
@ -20,11 +38,101 @@
|
|||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<!-- 앱 목록 · 홈 화면 아이콘은 아래 activity-alias (환경 설정 > 일반 > 앱 아이콘 에서 고른 것 하나만 켬) -->
|
||||
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="content"/>
|
||||
<data android:scheme="file"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
<!-- 공유 메뉴: 동영상 하나 · 여러 개 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- 아이콘이 여는 중계 화면 (MainActivity 를 열고 바로 닫힘: 아이콘을 바꿔도 앱 화면이 닫히지 않게) -->
|
||||
<activity
|
||||
android:name=".LauncherActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- 앱 아이콘 (고를 수 있는 것마다 하나, 처음에는 노랑만 켜짐) -->
|
||||
<activity-alias
|
||||
android:name=".IconYellow"
|
||||
android:targetActivity=".LauncherActivity"
|
||||
android:icon="@mipmap/ic_launcher_yellow"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".IconBlack"
|
||||
android:targetActivity=".LauncherActivity"
|
||||
android:icon="@mipmap/ic_launcher_black"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".IconFilmJj"
|
||||
android:targetActivity=".LauncherActivity"
|
||||
android:icon="@mipmap/ic_launcher_film_jj"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".IconFilm"
|
||||
android:targetActivity=".LauncherActivity"
|
||||
android:icon="@mipmap/ic_launcher_film"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".IconBlue"
|
||||
android:targetActivity=".LauncherActivity"
|
||||
android:icon="@mipmap/ic_launcher_blue"
|
||||
android:enabled="false"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<!-- 업데이트 APK 를 설치 화면에 넘기기 위한 파일 공유 (cache/updates 만) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/jj_file_paths"/>
|
||||
</provider>
|
||||
<service
|
||||
android:name=".KeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
|
||||
/// 다운로드 · MKV 만들기 · AI 자막이 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
|
||||
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
|
||||
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
|
||||
class KeepAliveService : Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val text = intent?.getStringExtra(EXTRA_TEXT) ?: ""
|
||||
val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1
|
||||
val n = notification(this, text, progress)
|
||||
running = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, n)
|
||||
}
|
||||
if (wakeLock == null) {
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs")
|
||||
.apply { acquire(6 * 60 * 60 * 1000L) }
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속)
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
running = false
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
wakeLock = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "jobs"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val EXTRA_TEXT = "text"
|
||||
const val EXTRA_PROGRESS = "progress"
|
||||
|
||||
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
|
||||
@Volatile
|
||||
var running = false
|
||||
|
||||
fun update(context: Context, text: String, progress: Int) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, notification(context, text, progress))
|
||||
}
|
||||
|
||||
private fun notification(context: Context, text: String, progress: Int): Notification {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "작업 진행", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "다운로드 · MKV 만들기 · AI 자막 진행 상황"
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
val open = PendingIntent.getActivity(
|
||||
context, 0,
|
||||
// 누르면 앱의 "작업 현황" 화면으로 (지금 하는 작업 · 진행률 · 대기열 · 다운로드)
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_SHOW_JOBS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(context, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") Notification.Builder(context)
|
||||
}
|
||||
b.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle("JJ_MKVMaker 작업 중")
|
||||
.setContentText(text)
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
if (progress in 0..100) b.setProgress(100, progress, false)
|
||||
return b.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
||||
/// 앱 목록 · 홈 화면 아이콘 (activity-alias) 이 여는 중계 화면: MainActivity 를 열고 바로 닫는다.
|
||||
/// MainActivity 작업이 alias 로 시작되면 환경 설정에서 아이콘을 바꿀 때 (그 alias 를 끄면) 앱 화면이 닫히므로.
|
||||
class LauncherActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,394 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
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.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비
|
||||
class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
|
||||
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
|
||||
}
|
||||
|
||||
private var channel: MethodChannel? = null
|
||||
|
||||
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
|
||||
private var pendingShowJobs = false
|
||||
|
||||
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
|
||||
private var pendingOpen: List<Map<String, String?>> = emptyList()
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
pendingOpen = openedFiles(intent)
|
||||
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
|
||||
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
|
||||
channel = ch
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"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)
|
||||
"storageVolumes" -> result.success(storageVolumes())
|
||||
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
|
||||
"installApk" -> try {
|
||||
installApk(call.argument<String>("path") ?: "")
|
||||
result.success(null)
|
||||
} catch (e: IllegalStateException) {
|
||||
// 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다
|
||||
result.error("PERMISSION", e.message ?: e.toString(), null)
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL", e.message ?: e.toString(), null)
|
||||
}
|
||||
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
|
||||
"keepAlive" -> {
|
||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
|
||||
result.success(null)
|
||||
}
|
||||
"stopKeepAlive" -> {
|
||||
stopService(Intent(this, 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 IllegalStateException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
|
||||
}
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
|
||||
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent.action == ACTION_SHOW_JOBS) {
|
||||
channel?.invokeMethod("showJobs", null)
|
||||
return
|
||||
}
|
||||
val files = openedFiles(intent)
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
askedNotifications = true
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
|
||||
}
|
||||
if (KeepAliveService.running) {
|
||||
KeepAliveService.update(this, text, progress)
|
||||
return
|
||||
}
|
||||
val i = Intent(this, KeepAliveService::class.java)
|
||||
.putExtra(KeepAliveService.EXTRA_TEXT, text)
|
||||
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i) else startService(i)
|
||||
} catch (e: Exception) {
|
||||
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
|
||||
android.util.Log.w("jj_mkvmaker", "keepAlive", e)
|
||||
}
|
||||
}
|
||||
|
||||
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_black_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_black_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_black_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_blue_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_blue_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_blue_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_film_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_film_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_film_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_film_jj_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_yellow_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_yellow_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_yellow_fg"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 34 KiB |