Compare commits
394 Commits
v2026.10.0
...
기능추가1
| Author | SHA1 | Date |
|---|---|---|
|
|
1b3160939c | |
|
|
5366bb3a14 | |
|
|
ab4c82d0b0 | |
|
|
3d3ab5666f | |
|
|
44c69af83e | |
|
|
9e40b485a9 | |
|
|
f43f067d4b | |
|
|
ed459a0229 | |
|
|
86b8ddd603 | |
|
|
dfed3fa6bb | |
|
|
f947935c19 | |
|
|
849c83031d | |
|
|
634fcbc920 | |
|
|
bd0801ce66 | |
|
|
716057734a | |
|
|
b1a43c36c5 | |
|
|
f45ad3b25f | |
|
|
55caef5c3f | |
|
|
0d86a9b163 | |
|
|
60b994a9e6 | |
|
|
2bacd88925 | |
|
|
484f11d9c2 | |
|
|
d2dc267ae2 | |
|
|
7f2263bbb9 | |
|
|
f5a01a1ecd | |
|
|
0ef2c2c33b | |
|
|
d7da812da6 | |
|
|
57676371dc | |
|
|
c593440249 | |
|
|
0bd1466212 | |
|
|
b2c400a0fb | |
|
|
f6de74010c | |
|
|
e228e84adb | |
|
|
aba095b342 | |
|
|
2f4ad6c146 | |
|
|
f377a14184 | |
|
|
84863c1569 | |
|
|
6e646b60c1 | |
|
|
a8bc585b1a | |
|
|
2033c48e5e | |
|
|
a19d68e167 | |
|
|
44a6c4e6b2 | |
|
|
197ba047c4 | |
|
|
954703c4f0 | |
|
|
8cce03d99c | |
|
|
6b60f348e2 | |
|
|
10954a632b | |
|
|
39c6d9dce4 | |
|
|
83e9fdd676 | |
|
|
f13de6b1d8 | |
|
|
ca63bd1fb5 | |
|
|
484991cdda | |
|
|
44407c36de | |
|
|
65f5e49aad | |
|
|
3704c7c2cc | |
|
|
6d40eaf174 | |
|
|
7cd8eb8b15 | |
|
|
22ae03953d | |
|
|
092b68a28f | |
|
|
62784039e4 | |
|
|
63ef362f6d | |
|
|
47165371eb | |
|
|
27baf91876 | |
|
|
b10158b46b | |
|
|
25a0cd3c96 | |
|
|
86a994690b | |
|
|
09fbe0337a | |
|
|
cda2a30664 | |
|
|
45e1a95df0 | |
|
|
1396ba41c8 | |
|
|
cfed8fa355 | |
|
|
722edc8ed1 | |
|
|
58254e42b2 | |
|
|
6366a2f880 | |
|
|
53f5c432bb | |
|
|
a74e7be59b | |
|
|
6567d8a5b4 | |
|
|
29b4365b74 | |
|
|
50bd2bf259 | |
|
|
25e3123aac | |
|
|
e4de7ed28e | |
|
|
160bfe85e0 | |
|
|
565ad3bd17 | |
|
|
6b6c23dc49 | |
|
|
02a79839da | |
|
|
53a74183a7 | |
|
|
11d709a97b | |
|
|
63b92b6956 | |
|
|
38f53e70cc | |
|
|
f6bdc35920 | |
|
|
f10858c68c | |
|
|
5038a60b87 | |
|
|
e7cd30ef41 | |
|
|
546b915b55 | |
|
|
19d4f7bf3d | |
|
|
8ca9184fdc | |
|
|
00e9685a33 | |
|
|
7c068ede3e | |
|
|
6434eb85d4 | |
|
|
4ae6845513 | |
|
|
9f758ac386 | |
|
|
71c995ee13 | |
|
|
e8baa846bb | |
|
|
a854299dda | |
|
|
7e2dc34b8d | |
|
|
17c1ea7a04 | |
|
|
d29593b2e6 | |
|
|
752707b842 | |
|
|
8eb1674382 | |
|
|
4070f8d2ee | |
|
|
88379f16f4 | |
|
|
9f94d8ea2c | |
|
|
8d6f288fab | |
|
|
fefe972937 | |
|
|
f28af04f77 | |
|
|
7e38ad8aec | |
|
|
49990c4120 | |
|
|
4bd0226efb | |
|
|
12c041eed0 | |
|
|
3a1a6391ca | |
|
|
d5e705152a | |
|
|
fba28cb027 | |
|
|
60398d22e8 | |
|
|
e512d75d57 | |
|
|
78486be27a | |
|
|
dd9e93b4fe | |
|
|
a80d8bcafe | |
|
|
b6e47178e3 | |
|
|
ba34014a1f | |
|
|
f4760cb4d7 | |
|
|
b0ac3c4581 | |
|
|
16f33b3b71 | |
|
|
c2c43825d6 | |
|
|
00fac175b9 | |
|
|
eefcb18ab8 | |
|
|
94ecd7b25e | |
|
|
23d3da8483 | |
|
|
0ce26e9124 | |
|
|
f9cd5f98c9 | |
|
|
818b4a0615 | |
|
|
e5a115d450 | |
|
|
d00df74257 | |
|
|
9a786f3496 | |
|
|
b5918b7aa2 | |
|
|
13c8e5f5bf | |
|
|
05074ec4bd | |
|
|
cd50edd666 | |
|
|
ab4d935fca | |
|
|
a223a10378 | |
|
|
a6b3f13e62 | |
|
|
0f3133cc68 | |
|
|
bb95be3589 | |
|
|
dc8a5f0b56 | |
|
|
3b6d50f409 | |
|
|
e0466d6168 | |
|
|
47144981d0 | |
|
|
c440b688ea | |
|
|
219510fec0 | |
|
|
eee38aa2fd | |
|
|
863b5e2ea0 | |
|
|
53a1b47a2c | |
|
|
5cdf57d7ff | |
|
|
58380e4ae0 | |
|
|
39dab23138 | |
|
|
b1f20914a0 | |
|
|
60336d342d | |
|
|
a780139eeb | |
|
|
5411558779 | |
|
|
5007d30afa | |
|
|
1b988cd84c | |
|
|
a15f6c25af | |
|
|
ef1cb069c1 | |
|
|
60d4db7583 | |
|
|
9032e8c4d7 | |
|
|
e39068cc9f | |
|
|
31a06c74ed | |
|
|
e658de6691 | |
|
|
8833c93bf8 | |
|
|
a2cb535f1e | |
|
|
f596ef7253 | |
|
|
1c6b008332 | |
|
|
8228d04a6e | |
|
|
92919aae93 | |
|
|
320025c5fa | |
|
|
1eef4ff65f | |
|
|
a3e898c66d | |
|
|
f9d3596bae | |
|
|
8937c17617 | |
|
|
99d446cea6 | |
|
|
12db3d43b1 | |
|
|
a4a5c4289a | |
|
|
62e8d52031 | |
|
|
a5017965cb | |
|
|
cab577ec8a | |
|
|
ed5a1407c1 | |
|
|
2d2f7055ba | |
|
|
6d322b1726 | |
|
|
6a782b2fdb | |
|
|
3327d9bf9d | |
|
|
1be9f58248 | |
|
|
031e8d3f84 | |
|
|
4ad66dd5a1 | |
|
|
c350499b79 | |
|
|
c41954b3ea | |
|
|
bf4b27a5f6 | |
|
|
f7d73da301 | |
|
|
0b0f2617ae | |
|
|
614584c78c | |
|
|
120d20fb86 | |
|
|
433f5b187a | |
|
|
36bbee615d | |
|
|
65c049d043 | |
|
|
6cb2dde089 | |
|
|
624076f787 | |
|
|
e32cd7d648 | |
|
|
69b8ab52ad | |
|
|
55f5676cf5 | |
|
|
6c61b8b82d | |
|
|
ba39f4bc92 | |
|
|
f0fad6c0e4 | |
|
|
e81fa6e5ba | |
|
|
8bfcb7ceda | |
|
|
a20a7bfe79 | |
|
|
70e6071991 | |
|
|
2239e70013 | |
|
|
c2c523a260 | |
|
|
5a0bc0ec50 | |
|
|
4cad606fe1 | |
|
|
01ef79f86f | |
|
|
58bc8fa35b | |
|
|
cdfcf360c4 | |
|
|
6470368c2e | |
|
|
893f6759ff | |
|
|
aa7a1af27c | |
|
|
3a9f09a6eb | |
|
|
037ddf502a | |
|
|
b0c3a4cee2 | |
|
|
54c4d6f97f | |
|
|
976f4633aa | |
|
|
b9739fd71c | |
|
|
f1206ae1f2 | |
|
|
d86a929ba1 | |
|
|
620cd63e34 | |
|
|
41d4d8afbf | |
|
|
90246717ec | |
|
|
d8ce0eb302 | |
|
|
d991ef91c1 | |
|
|
8c2ddc3385 | |
|
|
c070486c84 | |
|
|
27d50891d0 | |
|
|
c9c4f031b5 | |
|
|
487d955a99 | |
|
|
00d78f4853 | |
|
|
c0397e9fe1 | |
|
|
64ce867612 | |
|
|
b0d7f021f7 | |
|
|
44663ec2b9 | |
|
|
37d7c0da31 | |
|
|
e4b6e33a97 | |
|
|
f59e2f75a1 | |
|
|
3ffaf23955 | |
|
|
697235073e | |
|
|
954b86b84c | |
|
|
cd00d3ce3a | |
|
|
3ad8becafa | |
|
|
996491547b | |
|
|
a94009524c | |
|
|
df508e3d2d | |
|
|
850b148d9e | |
|
|
7df43a750e | |
|
|
a7632a9bed | |
|
|
3639aa9cbf | |
|
|
e9f7e5d819 | |
|
|
5e5182178b | |
|
|
e6556a086e | |
|
|
36de96dd0b | |
|
|
d6f3de130c | |
|
|
4f10cf167f | |
|
|
a7ef14382c | |
|
|
fe4ca6adb2 | |
|
|
0d1ea93fd3 | |
|
|
f86697c634 | |
|
|
ae51fdfa34 | |
|
|
d5bb200d00 | |
|
|
36e724afc1 | |
|
|
e46c73a317 | |
|
|
a66f3a8823 | |
|
|
f805403641 | |
|
|
968d62979c | |
|
|
ecb1cf93b7 | |
|
|
afdbcfb2d7 | |
|
|
b1f2b1aef4 | |
|
|
bf72557fc3 | |
|
|
06b6169ad8 | |
|
|
76f8ab18da | |
|
|
3a220ab073 | |
|
|
8c1ef5a03b | |
|
|
26f6389f02 | |
|
|
c986690b3c | |
|
|
f429c59502 | |
|
|
f33a4b3547 | |
|
|
5601f9d4fb | |
|
|
a37ae55e2d | |
|
|
388d01a0e3 | |
|
|
a4afdda5c6 | |
|
|
abeb3d8999 | |
|
|
bd0b75ce7b | |
|
|
cd81f6db37 | |
|
|
6010aa908b | |
|
|
4c0bc0b9ce | |
|
|
f331672e3a | |
|
|
794f484c19 | |
|
|
daac19a514 | |
|
|
cc05fcad3c | |
|
|
746082db22 | |
|
|
6a5aea5ff2 | |
|
|
6a90ae5706 | |
|
|
ba332f9f84 | |
|
|
c25a754035 | |
|
|
de967d190a | |
|
|
01600ec69a | |
|
|
1450b51316 | |
|
|
f97f63df79 | |
|
|
e1e3316d8d | |
|
|
8f5d2204f0 | |
|
|
1c919ce8c6 | |
|
|
f01fe96151 | |
|
|
7d76af382d | |
|
|
a878e3b1cc | |
|
|
f34cbe5a69 | |
|
|
e4524cdb9a | |
|
|
1cc2741b16 | |
|
|
ebe66a7714 | |
|
|
b098a301d8 | |
|
|
300ccdde99 | |
|
|
986688eafa | |
|
|
92556f62ed | |
|
|
7d6c653d5f | |
|
|
70056b0db3 | |
|
|
597d853943 | |
|
|
ec37fa28e4 | |
|
|
3dba07dd62 | |
|
|
951a1c927c | |
|
|
fead23ce2f | |
|
|
b39678f8f6 | |
|
|
9d7df5f555 | |
|
|
1be1bf928b | |
|
|
70e2b87c11 | |
|
|
ac8fd8ad9a | |
|
|
6eb0dfc70a | |
|
|
2553107aa5 | |
|
|
44ae09a31f | |
|
|
b3707e2ce5 | |
|
|
8f779fa7e6 | |
|
|
0cf7a9a7b7 | |
|
|
1ac48354af | |
|
|
c273297b11 | |
|
|
9ce55bbb74 | |
|
|
ae1592d244 | |
|
|
c5a44f2f3f | |
|
|
967b729e50 | |
|
|
fa0b7e56b2 | |
|
|
b38253fd67 | |
|
|
7308f8ce1b | |
|
|
626117995d | |
|
|
35ca463287 | |
|
|
a485d0d997 | |
|
|
aa9c431a47 | |
|
|
058a57e558 | |
|
|
99024d3c6e | |
|
|
bba955f6bf | |
|
|
ed9f00118c | |
|
|
6dfb4776a2 | |
|
|
81d6e6afa3 | |
|
|
7ddc32d5af | |
|
|
9ec7428cce | |
|
|
be0c08834d | |
|
|
0f5c1e8391 | |
|
|
d73281adfd | |
|
|
a973bcfda7 | |
|
|
758eb41d72 | |
|
|
a9f3cba991 | |
|
|
397fc6408b | |
|
|
8fec4ae091 | |
|
|
6adb217857 | |
|
|
bc14f66443 | |
|
|
38ae9fe9de | |
|
|
3a79510702 | |
|
|
e3282c2c38 | |
|
|
4257b97305 | |
|
|
7c487c41cd | |
|
|
d7efce87a6 | |
|
|
22364ec16a | |
|
|
ad01fd5671 |
12
README.md
|
|
@ -24,7 +24,7 @@
|
|||
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
|
||||
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
|
||||
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
|
||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
|
||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유, 다른 언어 페이지를 화면 언어로 자동 번역 (Google 번역), JavaScript 끄기 |
|
||||
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
||||
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
|
||||
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
|
||||
|
|
@ -76,10 +76,13 @@ 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 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀)
|
||||
|
||||
### 구조
|
||||
|
|
@ -101,9 +104,10 @@ Android 이식 시 `platform/android/` 구현과 `services/platform_services.dar
|
|||
## 라이선스
|
||||
|
||||
- JJ_MKVMaker 소스 코드: [MIT](LICENSE)
|
||||
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt)
|
||||
- FFmpeg (GPLv3) · aria2 (GPLv2) 는 수정하지 않은 별도 실행 파일로 함께 제공
|
||||
- libmpv (LGPL), whisper.cpp · ONNX Runtime (MIT), yt-dlp (Unlicense), Deno (MIT)
|
||||
- 포함 · 사용하는 구성 요소: [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) 입니다.**
|
||||
- **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다
|
||||
|
||||
## 주의
|
||||
|
|
|
|||
|
|
@ -1,82 +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 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
|
||||
|
||||
[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 등에서 받은 영상, 토렌트로 받은 파일의 저작권 책임은 이용자에게 있습니다.
|
||||
|
|
@ -17,6 +17,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -58,6 +58,23 @@ 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 {
|
||||
|
|
@ -86,4 +103,8 @@ 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,11 @@
|
|||
-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.**
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@
|
|||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<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"/>
|
||||
|
|
@ -9,14 +11,20 @@
|
|||
<!-- 앱이 화면에서 내려가도 다운로드 · 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: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"
|
||||
|
|
@ -24,7 +32,7 @@
|
|||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
|
@ -38,10 +46,7 @@
|
|||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- 앱 목록 · 홈 화면 아이콘은 아래 activity-alias (환경 설정 > 일반 > 앱 아이콘 에서 고른 것 하나만 켬) -->
|
||||
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
|
@ -59,6 +64,69 @@
|
|||
<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"
|
||||
|
|
@ -69,10 +137,16 @@
|
|||
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"/>
|
||||
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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -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,9 +23,13 @@ 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 n = notification(this, text, progress)
|
||||
val icon = intent?.getStringExtra(EXTRA_ICON) ?: ""
|
||||
val n = notification(this, text, progress, icon)
|
||||
running = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
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)
|
||||
|
|
@ -33,7 +37,12 @@ class KeepAliveService : Service() {
|
|||
if (wakeLock == null) {
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs")
|
||||
.apply { acquire(6 * 60 * 60 * 1000L) }
|
||||
.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(RENEW_MS * 2)
|
||||
}
|
||||
// 22: 6시간이 지나면 잠금이 풀려 화면을 끈 채 걸어 둔 동기화 · 긴 작업이 조용히 멈출 수 있었다 → 작업 중에는 늘 다시 건다
|
||||
renewer.postDelayed(renew, RENEW_MS)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
|
@ -43,7 +52,25 @@ 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
|
||||
|
|
@ -53,31 +80,100 @@ class KeepAliveService : Service() {
|
|||
companion object {
|
||||
const val CHANNEL_ID = "jobs"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val EXTRA_TEXT = "text"
|
||||
const val EXTRA_PROGRESS = "progress"
|
||||
const val STOPPED_ID = 2
|
||||
const val DONE_ID = 3
|
||||
|
||||
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
|
||||
@Volatile
|
||||
var running = false
|
||||
const val DONE_CHANNEL_ID = "done"
|
||||
|
||||
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))
|
||||
/// 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 notification(context: Context, text: String, progress: Int): Notification {
|
||||
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(CHANNEL_ID) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "작업 진행", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "다운로드 · MKV 만들기 · AI 자막 진행 상황"
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(DONE_CHANNEL_ID) == null) {
|
||||
createDoneChannel(context, nm)
|
||||
}
|
||||
val open = PendingIntent.getActivity(
|
||||
context, 0,
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
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) {
|
||||
|
|
@ -85,8 +181,48 @@ class KeepAliveService : Service() {
|
|||
} else {
|
||||
@Suppress("DEPRECATION") Notification.Builder(context)
|
||||
}
|
||||
b.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle("JJ_MKVMaker 작업 중")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.jj.jj_mkvmaker
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
||||
/// 앱 목록 · 홈 화면 아이콘 (activity-alias) 이 여는 중계 화면: MainActivity 를 열고 바로 닫는다.
|
||||
/// MainActivity 작업이 alias 로 시작되면 환경 설정에서 아이콘을 바꿀 때 (그 alias 를 끄면) 앱 화면이 닫히므로.
|
||||
class LauncherActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +1,209 @@
|
|||
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) {
|
||||
result.error("INSTALL", e.message ?: e.toString(), null)
|
||||
// 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)
|
||||
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(this, KeepAliveService::class.java))
|
||||
stopService(Intent(applicationContext, KeepAliveService::class.java))
|
||||
result.success(null)
|
||||
}
|
||||
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
|
||||
|
|
@ -66,7 +216,7 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
|
||||
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
|
||||
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용한 뒤 다시 [업데이트])
|
||||
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
|
||||
private fun installApk(path: String) {
|
||||
val src = File(path)
|
||||
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
|
||||
|
|
@ -75,7 +225,7 @@ class MainActivity : FlutterActivity() {
|
|||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
throw IllegalStateException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
|
||||
throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
|
||||
}
|
||||
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
|
||||
val dir = File(cacheDir, "updates").apply { mkdirs() }
|
||||
|
|
@ -95,9 +245,16 @@ 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)
|
||||
}
|
||||
|
|
@ -206,6 +363,7 @@ class MainActivity : FlutterActivity() {
|
|||
"path" to dir.absolutePath,
|
||||
"label" to (v.getDescription(this) ?: dir.name),
|
||||
"removable" to v.isRemovable,
|
||||
"primary" to v.isPrimary,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -221,6 +379,96 @@ 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
|
||||
|
|
@ -265,22 +513,24 @@ class MainActivity : FlutterActivity() {
|
|||
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
|
||||
private var askedNotifications = false
|
||||
|
||||
private fun keepAlive(text: String, progress: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications &&
|
||||
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(this, text, progress)
|
||||
KeepAliveService.update(applicationContext, text, progress, icon)
|
||||
return
|
||||
}
|
||||
val i = Intent(this, KeepAliveService::class.java)
|
||||
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) startForegroundService(i) else startService(i)
|
||||
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)
|
||||
|
|
@ -333,6 +583,24 @@ 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()
|
||||
|
|
@ -353,4 +621,29 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_black_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_black_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_black_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_blue_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_blue_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_blue_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_film_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_film_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_film_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_film_jj_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_yellow_bg"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_yellow_fg"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_yellow_fg"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 16 KiB |