Compare commits

..

No commits in common. "기능추가1" and "v2026.10.04_003" have entirely different histories.

723 changed files with 1221 additions and 113798 deletions

View File

@ -24,7 +24,7 @@
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유, 다른 언어 페이지를 화면 언어로 자동 번역 (Google 번역), JavaScript 끄기 |
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
@ -76,13 +76,10 @@ git clone <이 저장소>
cd app
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_rsync.ps1 # third_party\rsync\windows 에 rsync (MSYS2, SHA256 고정) 준비
flutter pub get
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 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀)
### 구조
@ -104,10 +101,9 @@ Android 이식 시 `platform/android/` 구현과 `services/platform_services.dar
## 라이선스
- JJ_MKVMaker 소스 코드: [MIT](LICENSE)
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](assets/licenses/THIRD_PARTY_NOTICES.txt) (라이선스 원문: [assets/licenses](assets/licenses))
- FFmpeg · yt-dlp 실행 파일 · rsync (GPLv3+) · aria2 (GPLv2+) 는 수정하지 않은 별도 실행 파일로 함께 제공
- libmpv (LGPL), PDFium (BSD), whisper.cpp · ONNX Runtime · stable-diffusion.cpp (MIT), Deno (MIT)
- **Android 판은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync) 를 포함하며, 대응 소스는 같은 판 번호의 태그 (예: v1.2.3) 입니다.**
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt)
- FFmpeg (GPLv3) · aria2 (GPLv2) 는 수정하지 않은 별도 실행 파일로 함께 제공
- libmpv (LGPL), whisper.cpp · ONNX Runtime (MIT), yt-dlp (Unlicense), Deno (MIT)
- **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다
## 주의

82
THIRD_PARTY_NOTICES.txt Normal file
View File

@ -0,0 +1,82 @@
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 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
[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
- 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

@ -17,8 +17,6 @@ analyzer:
- 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:
# The lint rules applied to this project can be customized in the

View File

@ -58,23 +58,6 @@ android {
}
}
// 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 {
@ -103,8 +86,4 @@ dependencies {
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")
}

View File

@ -5,11 +5,3 @@
-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,6 +4,4 @@
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>

View File

@ -1,7 +1,5 @@
<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"/>
@ -11,28 +9,18 @@
<!-- 앱이 화면에서 내려가도 다운로드 · 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
android:label="JJ_MKVMaker"
android:name="${applicationName}"
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" />
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@ -46,7 +34,10 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- 앱 목록 · 홈 화면 아이콘은 아래 activity-alias (환경 설정 > 일반 > 앱 아이콘 에서 고른 것 하나만 켬) -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
@ -64,69 +55,6 @@
<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-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"
@ -137,16 +65,10 @@
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>
android:foregroundServiceType="dataSync"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -12,7 +12,7 @@ import android.os.Build
import android.os.IBinder
import android.os.PowerManager
/// 다운로드 · MKV 만들기 · AI 자막 · 실시간 동기화 (백그라운드로 실행) 가 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
/// 다운로드 · MKV 만들기 · AI 자막이 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
class KeepAliveService : Service() {
@ -23,13 +23,9 @@ class KeepAliveService : Service() {
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)
val n = notification(this, text, progress)
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) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, n)
@ -37,12 +33,7 @@ class KeepAliveService : Service() {
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)
.apply { acquire(6 * 60 * 60 * 1000L) }
}
return START_NOT_STICKY
}
@ -52,25 +43,7 @@ class KeepAliveService : Service() {
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
@ -80,139 +53,31 @@ class KeepAliveService : Service() {
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) {
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, icon))
nm.notify(NOTIFICATION_ID, notification(context, text, progress))
}
private fun notification(context: Context, text: String, progress: Int, icon: String): Notification {
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) {
createJobsChannel(context, nm)
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),
Intent(context, MainActivity::class.java).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) {
@ -220,9 +85,8 @@ class KeepAliveService : Service() {
} 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"))
b.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("JJ_MKVMaker 작업 중")
.setContentText(text)
.setContentIntent(open)
.setOngoing(true)

View File

@ -1,19 +0,0 @@
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,209 +1,59 @@
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.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 저장소 전체 접근 (동영상 옆 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)
result.error("INSTALL", e.message ?: e.toString(), 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") ?: "")
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
result.success(null)
}
"stopKeepAlive" -> {
stopService(Intent(applicationContext, KeepAliveService::class.java))
stopService(Intent(this, KeepAliveService::class.java))
result.success(null)
}
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
@ -216,7 +66,7 @@ class MainActivity : FlutterActivity() {
}
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용한 뒤 다시 [업데이트])
private fun installApk(path: String) {
val src = File(path)
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
@ -225,7 +75,7 @@ class MainActivity : FlutterActivity() {
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
throw IllegalStateException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
}
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
val dir = File(cacheDir, "updates").apply { mkdirs() }
@ -245,16 +95,9 @@ class MainActivity : FlutterActivity() {
)
}
/// 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)
}
@ -363,7 +206,6 @@ class MainActivity : FlutterActivity() {
"path" to dir.absolutePath,
"label" to (v.getDescription(this) ?: dir.name),
"removable" to v.isRemovable,
"primary" to v.isPrimary,
)
)
}
@ -379,96 +221,6 @@ class MainActivity : FlutterActivity() {
}
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 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
@ -513,24 +265,22 @@ class MainActivity : FlutterActivity() {
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (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 &&
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(applicationContext, text, progress, icon)
KeepAliveService.update(this, text, progress)
return
}
val i = Intent(applicationContext, KeepAliveService::class.java)
val i = Intent(this, 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)
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)
@ -583,24 +333,6 @@ class MainActivity : FlutterActivity() {
)
}
/// 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()
@ -621,29 +353,4 @@ class MainActivity : FlutterActivity() {
)
}
}
/// 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

@ -1,66 +0,0 @@
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

@ -1,253 +0,0 @@
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

@ -1,5 +0,0 @@
<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

@ -1,5 +0,0 @@
<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

@ -1,5 +0,0 @@
<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

@ -1,5 +0,0 @@
<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

@ -1,5 +0,0 @@
<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

@ -1,5 +0,0 @@
<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

@ -1,10 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

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