Compare commits

..

No commits in common. "old-history-local" and "기능추가1" have entirely different histories.

1072 changed files with 170776 additions and 2010 deletions

13
.gitignore vendored
View File

@ -56,3 +56,16 @@ app.*.map.json
/jj_aria2/ /jj_aria2/
*.log *.log
/android/local.properties /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

View File

@ -24,14 +24,17 @@
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) | | **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 | | **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 | | **다운로드** | 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 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | | **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 |
## 실행 ## 실행
1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다. 1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다.
2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트) 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 재생 목록 | | 플레이어 | Space 재생/정지 · ←/→ 10초 · Shift+←/→ 1분 · ↑/↓ 음량 · F 전체 화면 · Esc 나가기 · N/P 다음/이전 · M 음소거 · S 자막 · L 재생 목록 |
| 자막 편집 | Ctrl+Space 재생/정지 · Alt+←/→ 1초 · Alt+Shift+←/→ 0.1초 · F7 새 줄 · F8 선택 줄 재생 · F9 시작=현재 · F10 끝=현재 | | 자막 편집 | 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+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` 로 적힙니다)
## 소스에서 빌드 ## 소스에서 빌드
@ -68,10 +76,13 @@ git clone <이 저장소>
cd app cd app
powershell -File tool\fetch_ffmpeg.ps1 # third_party\ffmpeg\windows 에 FFmpeg 준비 powershell -File tool\fetch_ffmpeg.ps1 # third_party\ffmpeg\windows 에 FFmpeg 준비
powershell -File tool\fetch_tools.ps1 # third_party\tools\windows 에 yt-dlp · aria2 · Deno 준비 powershell -File tool\fetch_tools.ps1 # third_party\tools\windows 에 yt-dlp · aria2 · Deno 준비
powershell -File tool\fetch_rsync.ps1 # third_party\rsync\windows 에 rsync (MSYS2, SHA256 고정) 준비
flutter pub get flutter pub get
flutter build windows --release # build\windows\x64\runner\Release flutter build windows --release # build\windows\x64\runner\Release
``` ```
Android 의 rsync: Git Bash 에서 `sh tool/build_rsync_android.sh` (Android NDK 로 rsync 3.4.1 소스를 빌드해 third_party/rsync/android 에 둠, APK 에 들어감)
테스트: `flutter test` (AI 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀) 테스트: `flutter test` (AI 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀)
### 구조 ### 구조
@ -93,9 +104,10 @@ Android 이식 시 `platform/android/` 구현과 `services/platform_services.dar
## 라이선스 ## 라이선스
- JJ_MKVMaker 소스 코드: [MIT](LICENSE) - JJ_MKVMaker 소스 코드: [MIT](LICENSE)
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt) - 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](assets/licenses/THIRD_PARTY_NOTICES.txt) (라이선스 원문: [assets/licenses](assets/licenses))
- FFmpeg (GPLv3) · aria2 (GPLv2) 는 수정하지 않은 별도 실행 파일로 함께 제공 - FFmpeg · yt-dlp 실행 파일 · rsync (GPLv3+) · aria2 (GPLv2+) 는 수정하지 않은 별도 실행 파일로 함께 제공
- libmpv (LGPL), whisper.cpp · ONNX Runtime (MIT), yt-dlp (Unlicense), Deno (MIT) - libmpv (LGPL), PDFium (BSD), whisper.cpp · ONNX Runtime · stable-diffusion.cpp (MIT), Deno (MIT)
- **Android 판은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync) 를 포함하며, 대응 소스는 같은 판 번호의 태그 (예: v1.2.3) 입니다.**
- **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다 - **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다
## 주의 ## 주의

View File

@ -1,66 +0,0 @@
JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
==========================================================
이 프로그램은 아래 오픈 소스 구성 요소를 포함하거나 내려받아 사용합니다.
각 구성 요소는 해당 라이선스를 따릅니다. 라이선스 원문은 같은 폴더(또는 ffmpeg\, tools\)의
*_LICENSE*, *_COPYING* 파일과 프로그램 안의 "환경 설정 > 라이선스" 화면에서 볼 수 있습니다.
[프로그램에 연결된 라이브러리]
- Flutter / Dart BSD 3-Clause https://github.com/flutter/flutter
- media_kit, media_kit_video MIT https://github.com/media-kit/media-kit
- libmpv (libmpv-2.dll) LGPL-2.1+ (-Dgpl=false 빌드)
https://github.com/mpv-player/mpv
빌드: https://github.com/media-kit/libmpv-win32-video-build
· libmpv 안의 FFmpeg LGPL-3.0 (--enable-version3, GPL 옵션 없음)
· libass, libplacebo 등 ISC / LGPL-2.1+
libmpv-2.dll 은 별도 파일이며, 같은 인터페이스의 다른 빌드로 바꿔 쓸 수 있습니다.
- ANGLE (libEGL.dll, libGLESv2.dll) BSD 3-Clause https://chromium.googlesource.com/angle/angle
- whisper.cpp / ggml MIT https://github.com/ggml-org/whisper.cpp
- whisper_ggml (JJ_MKVMaker 수정본) MIT https://pub.dev/packages/whisper_ggml
수정 내용: app\packages\whisper_ggml\JJ_PATCH.md
- ONNX Runtime (onnxruntime.dll) MIT https://github.com/microsoft/onnxruntime
- onnxruntime (Dart) MIT https://pub.dev/packages/onnxruntime
- window_manager, tray_manager, hotkey_manager, screen_retriever, nativeapi
MIT https://github.com/leanflutter
- flutter_inappwebview Apache-2.0 https://pub.dev/packages/flutter_inappwebview
- Microsoft Edge WebView2 SDK (WebView2Loader.dll)
BSD 3-Clause (Microsoft) https://www.nuget.org/packages/Microsoft.Web.WebView2
앱 안 브라우저는 Windows 에 설치된 Edge WebView2 런타임을 사용합니다 (Windows 11 기본 포함).
- desktop_drop Apache-2.0 https://pub.dev/packages/desktop_drop
- file_picker MIT https://pub.dev/packages/file_picker
- charset Apache-2.0 https://pub.dev/packages/charset
- cp949_codec BSD https://pub.dev/packages/cp949_codec
- Microsoft Visual C++ 런타임 (msvcp140.dll, vcruntime140*.dll)
Microsoft 재배포 가능 파일 (Visual Studio 라이선스의 Distributable Code)
- 그 밖의 Dart 패키지 라이선스: 프로그램의 "환경 설정 > 라이선스" 화면
[별도 실행 파일로 함께 제공 - 프로그램과 결합되지 않고 명령줄로 실행]
- FFmpeg (ffmpeg\ffmpeg.exe) GPL-3.0 https://ffmpeg.org
빌드: gyan.dev "release essentials" https://www.gyan.dev/ffmpeg/builds/
소스: https://ffmpeg.org/download.html 및 위 빌드 페이지의 소스 링크
원문: ffmpeg\FFMPEG_LICENSE.txt
- aria2 (tools\aria2c.exe) GPL-2.0-or-later https://github.com/aria2/aria2
소스: https://github.com/aria2/aria2/releases/tag/release-1.37.0
원문: tools\ARIA2_COPYING.txt
- yt-dlp (tools\yt-dlp.exe) Unlicense (퍼블릭 도메인) https://github.com/yt-dlp/yt-dlp
원문: tools\YT-DLP_LICENSE.txt
- Deno (tools\deno.exe) MIT https://github.com/denoland/deno
원문: tools\DENO_LICENSE.txt
위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
[처음 사용할 때 내려받는 AI 모델]
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp
- NLLB-200 distilled 600M (ONNX) CC-BY-NC 4.0 (비상업적 이용만 허용)
https://huggingface.co/facebook/nllb-200-distilled-600M
변환본: https://huggingface.co/Xenova/nllb-200-distilled-600M
※ NLLB 번역 모델은 상업적 용도로 쓸 수 없습니다. 판매·유료 서비스에 쓰려면
상업 이용이 가능한 번역 모델로 바꿔야 합니다.
[인터넷 서비스]
- OpenSubtitles.com REST API: 사용자가 발급받은 API 키로 이용하며 OpenSubtitles 이용 약관을 따릅니다.
- YouTube 등에서 받은 영상, 토렌트로 받은 파일의 저작권 책임은 이용자에게 있습니다.

View File

@ -14,6 +14,11 @@ analyzer:
- build/** - build/**
- android/** - android/**
- windows/** - windows/**
- packages/webview_cef/**
- packages/flutter_inappwebview_android/**
- packages/ffmpeg_kit_flutter_new_min_gpl/** # 고친 외부 부품 (원본 코드 그대로)
- packages/flutter_inappwebview_windows/** # 고친 외부 부품 (Tor 화면 다운로드 막기 · 프록시 인증, JJ_PATCH.md)
- packages/whisper_ggml/** # 고친 외부 부품 (자기 analysis_options 가 flutter_lints 를 찾지 못함)
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the

View File

@ -1,13 +1,22 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.jj.jj_mkvmaker" namespace = "com.jj.jj_mkvmaker"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion // whisper_ggml 이 NDK 29 를 요구 (더 낮은 버전용 코드와 호환)
ndkVersion = "29.0.13113456"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
@ -15,25 +24,63 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.jj.jj_mkvmaker" applicationId = "com.jj.jj_mkvmaker"
// You can update the following values to match your application needs. // ffmpeg-kit 은 Android 7.0 (API 24) 이상
// For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = maxOf(24, flutter.minSdkVersion)
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // 버전 "년.월.일+순번" 의 순번은 날마다 1 부터 다시 시작하므로 그대로 쓰면 다음 날 판이 "더 낮은 버전" 으로
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // 설치가 거부된다. 날짜를 앞에 붙여 늘 커지게 한다: 2026.10.2+1 → 261002001
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` versionCode = flutter.versionName.split(".").let { (y, m, d) ->
// flag during build. (y.toInt() % 100) * 10_000_000 + m.toInt() * 100_000 + d.toInt() * 1_000 + flutter.versionCode
versionCode = flutter.versionCode }
versionName = flutter.versionName 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 { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("debug") }
}
// rsync (librsync.so 라는 이름의 실행 파일): tool/build_rsync_android.sh 로 third_party/rsync/android 에 준비.
// 네이티브 라이브러리 폴더에 풀려 실행할 수 있다 (앱 데이터 폴더의 파일은 Android 10+ 에서 실행 불가)
sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android")
// 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh
sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android")
// Tor (libtor.so 라는 이름의 실행 파일): 공식 Tor Expert Bundle (Android aarch64) 의 libTor.so, tool/fetch_tor_android.ps1 (판 · SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
// 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android")
// 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet()
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android").forEach { dir ->
fileTree(dir).matching { include("**/*.so") }.forEach { so ->
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
}
}
// ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드)
packaging {
jniLibs {
useLegacyPackaging = true
// 64비트 ARM 만 (위 abiFilters 와 같은 뜻, 플러그인 라이브러리까지 확실히 빼기)
excludes += setOf("lib/x86_64/**", "lib/x86/**", "lib/armeabi-v7a/**")
} }
} }
} }
@ -47,3 +94,17 @@ kotlin {
flutter { flutter {
source = "../.." 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")
// Tor 화면: 웹 뷰 기능 확인 · 전용 프로필 지우기 (flutter_inappwebview_android 와 같은 판)
implementation("androidx.webkit:webkit:1.12.0")
// 88: Android SMB (SMB2 · SMB3, SMB1 없음) - Apache-2.0. 고지: THIRD_PARTY_NOTICES_ANDROID · 라이선스 화면
implementation("com.hierynomus:smbj:0.15.0")
}

15
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,15 @@
# 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.**
# 88: smbj (SMB) - 메시지 · 보안 제공자를 이름으로 찾는다 (축소가 지우면 연결 · 로그인이 실패)
-keep class com.hierynomus.** { *; }
-keep class net.engio.mbassy.** { *; }
-keep class org.bouncycastle.** { *; }
-dontwarn com.hierynomus.**
-dontwarn net.engio.mbassy.**
-dontwarn org.bouncycastle.**
-dontwarn org.slf4j.**

View File

@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>

View File

@ -1,12 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 28 APK 점검 · 관리자 판단 (10/19): Wi-Fi 공유 · SMB 찾기가 망 종류 (Wi-Fi · 유선 / 모바일 데이터 · VPN) 를 알기 위해. 일반 권한 (묻지 않음) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<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.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<!-- 예전 버전으로 되돌릴 때 이 앱을 지우는 확인 창 (Android 는 낮은 버전을 위에 설치하지 못함) -->
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<application <application
android:label="jj_mkvmaker" android:label="JJ_MKVMaker"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
<property
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
android:value="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@ -20,11 +46,107 @@
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/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> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </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>
<!-- 작업 알림 서비스 (28 APK 점검 · 관리자 지시 10/19): specialUse 설명은 실제로 하는 일 모두를 적는다
(실시간 동기화 · 동영상 변환 · AI 자막 · 다운로드 · 복사/이동 · 원격 서버 전송 · Wi-Fi 공유) -->
<service
android:name=".KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/>
</service>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@ -0,0 +1,234 @@
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 icon = intent?.getStringExtra(EXTRA_ICON) ?: ""
val n = notification(this, text, progress, icon)
running = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// 실시간 동기화는 앱이 켜져 있는 내내 지켜봐야 하므로 하루 6시간 제한이 있는 dataSync 대신 specialUse (Android 14+)
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else 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 {
setReferenceCounted(false)
acquire(RENEW_MS * 2)
}
// 22: 6시간이 지나면 잠금이 풀려 화면을 끈 채 걸어 둔 동기화 · 긴 작업이 조용히 멈출 수 있었다 → 작업 중에는 늘 다시 건다
renewer.postDelayed(renew, RENEW_MS)
}
return START_NOT_STICKY
}
/// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속)
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf()
}
private val renewer = android.os.Handler(android.os.Looper.getMainLooper())
private val renew = object : Runnable {
override fun run() {
wakeLock?.let { if (running) it.acquire(RENEW_MS * 2) }
if (running) renewer.postDelayed(this, RENEW_MS)
}
}
/// 최근 앱에서 밀어 닫음: "백그라운드로 실행" 이 꺼져 있으면 작업이 멈추므로 알림을 남기고 서비스도 끝낸다 (21)
override fun onTaskRemoved(rootIntent: Intent?) {
if (!MainActivity.background) {
notifyStopped(this)
stopSelf()
}
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
renewer.removeCallbacks(renew)
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 STOPPED_ID = 2
const val DONE_ID = 3
const val DONE_CHANNEL_ID = "done"
/// 57: 알림 글 · 채널 이름을 앱 화면 언어로 (앱이 고른 언어라 Android 의 values-xx 로는 맞출 수 없다).
/// 앱 (Dart) 이 언어를 바꿀 때마다 보내 오고, 다음에 켤 때도 쓰게 저장해 둔다. 없으면 한국어.
private const val PREFS = "jj_notify_texts"
private val defaults = mapOf(
"working" to "JJ_MKVMaker 작업 중",
"stoppedTitle" to "JJ_MKVMaker 작업이 멈췄습니다",
"stoppedText" to "앱을 닫아 진행 중이던 작업이 멈췄습니다. 닫아도 계속하려면 환경 설정에서 '백그라운드로 실행' 을 켜세요.",
"stoppedLong" to "앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.",
"jobsChannel" to "작업 진행",
"jobsChannelDesc" to "다운로드 · MKV 만들기 · AI 자막 · 동기화 진행 상황",
"doneChannel" to "끝난 작업",
"doneChannelDesc" to "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때",
)
private fun t(context: Context, key: String): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(key, null)?.takeIf { it.isNotEmpty() }
?: defaults.getValue(key)
/// 앱이 보낸 글을 저장하고, 이미 만든 채널은 이름 · 설명을 바꾼다 (같은 ID 로 다시 만들면 이름만 바뀐다)
fun setTexts(context: Context, texts: Map<String, String>) {
val e = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
for ((k, v) in texts) if (k in defaults) e.putString(k, v)
e.apply()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ID) != null) createJobsChannel(context, nm, texts)
if (nm.getNotificationChannel(DONE_CHANNEL_ID) != null) createDoneChannel(context, nm, texts)
}
}
private fun pick(context: Context, texts: Map<String, String>?, key: String) =
texts?.get(key)?.takeIf { it.isNotEmpty() } ?: t(context, key)
private fun createJobsChannel(context: Context, nm: NotificationManager, texts: Map<String, String>? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
nm.createNotificationChannel(
NotificationChannel(CHANNEL_ID, pick(context, texts, "jobsChannel"), NotificationManager.IMPORTANCE_LOW).apply {
description = pick(context, texts, "jobsChannelDesc")
setShowBadge(false)
}
)
}
private fun createDoneChannel(context: Context, nm: NotificationManager, texts: Map<String, String>? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
nm.createNotificationChannel(
NotificationChannel(DONE_CHANNEL_ID, pick(context, texts, "doneChannel"), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = pick(context, texts, "doneChannelDesc")
setSound(null, null)
}
)
}
/// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로.
/// 진행 알림 채널 (조용히, 낮은 중요도) 과 따로 두어 알림 창의 "조용한 알림" 칸에 묻히지 않게 (보통 중요도, 소리 없음)
fun notifyDone(context: Context, title: String, text: String) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(DONE_CHANNEL_ID) == null) {
createDoneChannel(context, nm)
}
val open = PendingIntent.getActivity(
context, 3,
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, DONE_CHANNEL_ID)
} else {
@Suppress("DEPRECATION") Notification.Builder(context)
}
b.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(title)
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setContentIntent(open)
.setAutoCancel(true)
nm.notify(DONE_ID, b.build())
}
/// 잠금을 다시 거는 간격 (1시간, 잠금은 그 두 배 동안)
const val RENEW_MS = 60 * 60 * 1000L
/// 앱을 닫아 작업이 멈췄다는 알림 (누르면 앱을 다시 연다)
fun notifyStopped(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val open = PendingIntent.getActivity(
context, 1, Intent(context, MainActivity::class.java),
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_notify_error)
.setContentTitle(t(context, "stoppedTitle"))
.setContentText(t(context, "stoppedText"))
.setStyle(Notification.BigTextStyle().bigText(t(context, "stoppedLong")))
.setContentIntent(open)
.setAutoCancel(true)
nm.notify(STOPPED_ID, b.build())
}
const val EXTRA_TEXT = "text"
const val EXTRA_PROGRESS = "progress"
const val EXTRA_ICON = "icon"
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
@Volatile
var running = false
fun update(context: Context, text: String, progress: Int, icon: String) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification(context, text, progress, icon))
}
private fun notification(context: Context, text: String, progress: Int, icon: String): Notification {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) {
createJobsChannel(context, nm)
}
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)
}
// 다운로드 중이면 내려받기 아이콘, 그 밖 (실시간 동기화 · MKV · AI 자막) 은 좌우로 오가는 화살표
b.setSmallIcon(if (icon == "download") android.R.drawable.stat_sys_download else R.drawable.ic_stat_sync_anim)
.setContentTitle(t(context, "working"))
.setContentText(text)
.setContentIntent(open)
.setOngoing(true)
.setOnlyAlertOnce(true)
if (progress in 0..100) b.setProgress(100, progress, false)
return b.build()
}
}
}

View File

@ -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()
}
}

View File

@ -1,5 +1,649 @@
package com.jj.jj_mkvmaker package com.jj.jj_mkvmaker
import android.Manifest
import android.content.Context
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 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.FlutterEngineCache
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 const val ENGINE_ID = "main"
/// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기)
/// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
@Volatile
var background = false
}
/// 앞 화면이 닫힌 뒤 살아 있던 엔진을 다시 붙였는지 (Dart 는 이미 돌고 있다)
private var reusedEngine = false
/// 엔진은 앱 (프로세스) 이 갖는다: 화면이 닫혀도 [background] 면 살려 두고, 다시 열면 그 엔진을 붙인다
override fun provideFlutterEngine(context: Context): FlutterEngine {
val cache = FlutterEngineCache.getInstance()
cache.get(ENGINE_ID)?.let {
reusedEngine = true
return it
}
return FlutterEngine(context.applicationContext).also { cache.put(ENGINE_ID, it) }
}
/// 백그라운드로 실행이 꺼져 있으면 지금처럼 화면과 함께 엔진도 끝낸다
override fun shouldDestroyEngineWithHost(): Boolean {
if (background) return false
FlutterEngineCache.getInstance().remove(ENGINE_ID)
return true
}
/// 첫 화면에서 ←: 백그라운드로 실행이면 앱을 끝내지 않고 뒤로 보낸다 (홈 버튼과 같음)
override fun popSystemNavigator(): Boolean {
if (!background) return false
moveTaskToBack(true)
return true
}
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
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
if (reusedEngine) {
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
if (pendingShowJobs) ch.invokeMethod("showJobs", null)
if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen)
pendingShowJobs = false
pendingOpen = emptyList()
}
ch.setMethodCallHandler { call, result ->
when (call.method) {
"secretReadAll" -> {
try {
result.success(SecretBox.readAll(applicationContext))
} catch (e: Exception) {
result.error("secret", e.toString(), null)
}
}
"secretWrite" -> {
try {
SecretBox.write(applicationContext, call.argument<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") ?: ""))
// 예전 버전으로 되돌리기: 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())
"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)
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,
"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 업데이트) 은 메인 스레드 밖에서
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
}
}

View File

@ -0,0 +1,66 @@
package com.jj.jj_mkvmaker
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/// 비밀 값 (WebDAV · OpenSubtitles 비밀번호 등) 을 AndroidKeyStore 의 AES-GCM 키로 암호화해 앱 전용 SharedPreferences ("jj_secrets") 에 둔다.
/// 키는 이 기기 밖으로 나가지 않는다 (백업에서도 제외 - res/xml/backup_rules.xml). 풀지 못한 값은 지우지 않고 건너뛴다.
object SecretBox {
private const val ALIAS = "jj_secrets"
private const val PREFS = "jj_secrets"
private fun key(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return gen.generateKey()
}
private fun prefs(c: Context) = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun readAll(c: Context): Map<String, String> {
val all = prefs(c).all
if (all.isEmpty()) return emptyMap()
val k = key()
val out = HashMap<String, String>()
for ((name, v) in all) {
val raw = try { Base64.decode(v as String, Base64.NO_WRAP) } catch (_: Exception) { continue }
if (raw.size < 13) continue
try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, k, GCMParameterSpec(128, raw, 0, 12))
out[name] = String(cipher.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)
} catch (_: Exception) {
}
}
return out
}
fun write(c: Context, name: String, value: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
val body = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
val raw = cipher.iv + body
if (!prefs(c).edit().putString(name, Base64.encodeToString(raw, Base64.NO_WRAP)).commit()) {
throw IllegalStateException("secret write failed")
}
}
fun delete(c: Context, name: String) {
prefs(c).edit().remove(name).commit()
}
}

View File

@ -0,0 +1,253 @@
package com.jj.jj_mkvmaker
import android.os.Handler
import android.os.Looper
import com.hierynomus.msdtyp.AccessMask
import com.hierynomus.mserref.NtStatus
import com.hierynomus.msfscc.FileAttributes
import com.hierynomus.mssmb2.SMB2CreateDisposition
import com.hierynomus.mssmb2.SMB2ShareAccess
import com.hierynomus.mssmb2.SMBApiException
import com.hierynomus.security.bc.BCSecurityProvider
import com.hierynomus.smbj.SMBClient
import com.hierynomus.smbj.SmbConfig
import com.hierynomus.smbj.auth.AuthenticationContext
import com.hierynomus.smbj.connection.Connection
import com.hierynomus.smbj.session.Session
import com.hierynomus.smbj.share.DiskShare
import com.hierynomus.smbj.share.File as SmbFile
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.EnumSet
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/// 88: Android SMB (smbj, Apache-2.0). Windows 는 운영체제가 연다 (사용자 결정 88-1) - 이것은 Android 에만.
/// Dart (lib/core/smb.dart) 가 부른다: 서버 설정은 부를 때마다 함께 받고 (비밀번호는 저장하지 않음), 연결은 서버마다 하나를 다시 쓴다.
/// SMB1 은 쓰지 않는다 (smbj 는 SMB2 부터). 암호화: 서버가 되면 SMB3 암호화 (사용자 결정 88-4 - 안 되면 Dart 가 경고).
object SmbBridge {
private val pool = Executors.newCachedThreadPool()
private val main = Handler(Looper.getMainLooper())
private class Conn(val key: String, val client: SMBClient, val connection: Connection, val session: Session, val share: DiskShare)
/// 서버 id → 연결 (설정이 바뀌면 key 가 달라 새로 연다)
private val conns = ConcurrentHashMap<String, Conn>()
/// 열어 둔 파일 (조각으로 읽기 · 쓰기)
private val files = ConcurrentHashMap<Int, SmbFile>()
private val nextHandle = AtomicInteger(1)
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, "jj_mkvmaker/smb").setMethodCallHandler { call, result ->
pool.execute {
try {
val value = handle(call)
main.post { result.success(value) }
} catch (e: Throwable) {
val (code, message) = explain(e)
main.post { result.error(code, message, null) }
}
}
}
}
private fun handle(call: MethodCall): Any? {
when (call.method) {
"close" -> {
conns.remove(call.argument<String>("id")!!)?.let { closeQuietly(it) }
return null
}
"readChunk" -> {
val f = files[call.argument<Int>("handle")!!] ?: throw IllegalStateException("handle")
val len = call.argument<Int>("length")!!
val buf = ByteArray(len)
val n = f.read(buf, (call.argument<Number>("offset")!!).toLong())
return if (n <= 0) ByteArray(0) else buf.copyOf(n)
}
"writeChunk" -> {
val f = files[call.argument<Int>("handle")!!] ?: throw IllegalStateException("handle")
val data = call.argument<ByteArray>("data")!!
f.write(data, (call.argument<Number>("offset")!!).toLong())
return null
}
"closeHandle" -> {
files.remove(call.argument<Int>("handle")!!)?.let { runCatching { it.close() } }
return null
}
}
val c = conn(call)
val path = smbPath(call.argument<String>("path") ?: "")
return when (call.method) {
// 보안 검토 6: 이 연결 (세션) 이 실제로 암호화 · 서명되는지
"info" -> mapOf(
"encrypted" to runCatching { c.session.shouldEncryptData() }.getOrDefault(false),
"signed" to c.session.isSigningRequired,
"dialect" to c.connection.connectionContext.negotiatedProtocol.dialect.name,
)
"list" -> c.share.list(path).filter { it.fileName != "." && it.fileName != ".." }.map {
mapOf(
"name" to it.fileName,
"dir" to ((it.fileAttributes and FileAttributes.FILE_ATTRIBUTE_DIRECTORY.value) != 0L),
"size" to it.endOfFile,
"mtime" to it.lastWriteTime.toEpochMillis(),
)
}
"stat" -> {
if (path.isEmpty()) return mapOf("dir" to true, "size" to 0L, "mtime" to 0L)
if (!c.share.fileExists(path) && !c.share.folderExists(path)) return null
val info = c.share.getFileInformation(path)
mapOf(
"dir" to info.standardInformation.isDirectory,
"size" to info.standardInformation.endOfFile,
"mtime" to info.basicInformation.lastWriteTime.toEpochMillis(),
)
}
"mkdir" -> {
c.share.mkdir(path)
null
}
"delete" -> {
if (c.share.folderExists(path)) c.share.rmdir(path, true) else if (c.share.fileExists(path)) c.share.rm(path)
null
}
"move" -> {
val to = smbPath(call.argument<String>("to")!!)
val overwrite = call.argument<Boolean>("overwrite") ?: false
val entry = c.share.open(
path,
EnumSet.of(AccessMask.DELETE, AccessMask.FILE_READ_ATTRIBUTES),
null,
SMB2ShareAccess.ALL,
SMB2CreateDisposition.FILE_OPEN,
null,
)
entry.use { it.rename(to, overwrite) }
null
}
"openRead" -> {
val f = c.share.openFile(
path,
EnumSet.of(AccessMask.GENERIC_READ),
null,
SMB2ShareAccess.ALL,
SMB2CreateDisposition.FILE_OPEN,
null,
)
val h = nextHandle.getAndIncrement()
files[h] = f
mapOf("handle" to h, "size" to f.fileInformation.standardInformation.endOfFile)
}
"openWrite" -> {
val f = c.share.openFile(
path,
EnumSet.of(AccessMask.GENERIC_WRITE),
null,
SMB2ShareAccess.ALL,
SMB2CreateDisposition.FILE_OVERWRITE_IF,
null,
)
val h = nextHandle.getAndIncrement()
files[h] = f
h
}
"quota" -> {
val s = c.share.shareInformation
listOf(s.freeSpace, s.totalSpace - s.freeSpace)
}
else -> throw UnsupportedOperationException(call.method)
}
}
/// 서버 설정 → 연결 (같은 설정이면 다시 씀, 끊겼으면 새로)
private fun conn(call: MethodCall): Conn {
val id = call.argument<String>("id")!!
val host = call.argument<String>("host")!!
val port = call.argument<Int>("port") ?: 445
val shareName = call.argument<String>("share")!!
val user = call.argument<String>("user") ?: ""
val domain = call.argument<String>("domain") ?: ""
val password = call.argument<String>("password") ?: ""
val requireSecure = call.argument<Boolean>("requireSecure") ?: false
val key = listOf(host, port, shareName, user, domain, password.hashCode(), requireSecure).joinToString("|")
conns[id]?.let {
if (it.key == key && it.connection.isConnected) return it
conns.remove(id)
closeQuietly(it)
}
val config = SmbConfig.builder()
// Android 의 기본 보안 제공자에는 NTLM 에 필요한 MD4 가 없다 - Bouncy Castle 로
.withSecurityProvider(BCSecurityProvider())
.withEncryptData(true)
// 보안 검토 6: 설정 "SMB 서명 · 암호화 필수" 면 서명을 요구한다 (서버가 서명을 켜 두었으면 서명)
.withSigningRequired(requireSecure)
.withTimeout(30, TimeUnit.SECONDS)
.withSoTimeout(60, TimeUnit.SECONDS)
.build()
val client = SMBClient(config)
try {
val connection = client.connect(host, port)
val auth = if (user.isEmpty()) AuthenticationContext.guest()
else AuthenticationContext(user, password.toCharArray(), domain.ifEmpty { null })
val session = connection.authenticate(auth)
if (requireSecure && !session.isSigningRequired && !runCatching { session.shouldEncryptData() }.getOrDefault(false)) {
runCatching { session.close() }
throw SmbBridgeException("insecure", "signing and encryption are off")
}
val share = session.connectShare(shareName) as? DiskShare
?: throw SmbBridgeException("noShare", "공유 폴더가 아닙니다: $shareName")
val c = Conn(key, client, connection, session, share)
conns[id] = c
return c
} catch (e: Throwable) {
runCatching { client.close() }
throw e
}
}
private fun closeQuietly(c: Conn) {
runCatching { c.share.close() }
runCatching { c.session.close() }
runCatching { c.connection.close() }
runCatching { c.client.close() }
}
/// "/a/b" → "a\\b" (공유 안의 경로, 맨 앞 구분자 없음)
private fun smbPath(rel: String): String = rel.trim('/').replace('/', '\\')
class SmbBridgeException(val code: String, message: String) : Exception(message)
/// 오류 → (종류, 원문). 종류는 Dart 가 사람 말로 바꾼다
private fun explain(e: Throwable): Pair<String, String> {
val root = generateSequence(e) { it.cause }.last()
if (e is SmbBridgeException) return e.code to (e.message ?: "")
if (e is SMBApiException) {
val code = when (e.status) {
NtStatus.STATUS_LOGON_FAILURE, NtStatus.STATUS_ACCOUNT_DISABLED, NtStatus.STATUS_PASSWORD_EXPIRED,
NtStatus.STATUS_LOGON_TYPE_NOT_GRANTED -> "auth"
NtStatus.STATUS_ACCESS_DENIED -> "permission"
NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, NtStatus.STATUS_NO_SUCH_FILE -> "notFound"
NtStatus.STATUS_BAD_NETWORK_NAME -> "noShare"
NtStatus.STATUS_OBJECT_NAME_COLLISION -> "exists"
else -> "other"
}
return code to "${e.status}: ${e.message}"
}
if (root is UnknownHostException || root is ConnectException || root is NoRouteToHostException || root is SocketTimeoutException) {
return "unreachable" to "${root.javaClass.simpleName}: ${root.message}"
}
val text = "${e.javaClass.simpleName}: ${e.message} / ${root.javaClass.simpleName}: ${root.message}"
// SMB1 만 되는 서버 (smbj 는 SMB2 부터 - 협상 실패)
if (text.contains("SMB1", ignoreCase = true) || text.contains("dialect", ignoreCase = true) || text.contains("negotiat", ignoreCase = true)) {
return "smb1" to text
}
return "other" to text
}
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M2,7 L14,7 L14,4 L19,8 L14,12 L14,9 L2,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M22,15 L10,15 L10,12 L5,16 L10,20 L10,17 L22,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3.5,7 L15.5,7 L15.5,4 L20.5,8 L15.5,12 L15.5,9 L3.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M20.5,15 L8.5,15 L8.5,12 L3.5,16 L8.5,20 L8.5,17 L20.5,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5,7 L17,7 L17,4 L22,8 L17,12 L17,9 L5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M19,15 L7,15 L7,12 L2,16 L7,20 L7,17 L19,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M6.5,7 L18.5,7 L18.5,4 L23.5,8 L18.5,12 L18.5,9 L6.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M17.5,15 L5.5,15 L5.5,12 L0.5,16 L5.5,20 L5.5,17 L17.5,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5,7 L17,7 L17,4 L22,8 L17,12 L17,9 L5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M19,15 L7,15 L7,12 L2,16 L7,20 L7,17 L19,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3.5,7 L15.5,7 L15.5,4 L20.5,8 L15.5,12 L15.5,9 L3.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M20.5,15 L8.5,15 L8.5,12 L3.5,16 L8.5,20 L8.5,17 L20.5,17 Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 실시간 동기화 · 작업 중 알림 아이콘: 위 화살표는 오른쪽, 아래 화살표는 왼쪽으로 오간다 -->
<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
<item android:drawable="@drawable/ic_stat_sync_0" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_1" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_2" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_3" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_4" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_5" android:duration="150"/>
</animation-list>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More