Compare commits
443 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 | |
|
|
9dea4b3e5e | |
|
|
98122539e7 | |
|
|
52deef4118 | |
|
|
bfdb554b31 | |
|
|
d4ab2f87b7 | |
|
|
61da57aea1 | |
|
|
31a4672971 | |
|
|
a64f135774 | |
|
|
26b7b7ac13 | |
|
|
1410dad2aa | |
|
|
9044dd6d46 | |
|
|
6184b6ea71 | |
|
|
480209b45d | |
|
|
b466bff15f | |
|
|
7df26039c9 | |
|
|
9a0d361565 | |
|
|
fb92349176 | |
|
|
2a4529392c | |
|
|
bca0c04459 | |
|
|
14b2489938 | |
|
|
d89bd8a245 | |
|
|
8c03efd489 | |
|
|
34ddd57e62 | |
|
|
58e32b6314 | |
|
|
80d84ab169 | |
|
|
6c9f452e43 | |
|
|
772fbabb62 | |
|
|
07ec8cd4a5 | |
|
|
39f65f414c | |
|
|
6c15f74e72 | |
|
|
a459488795 | |
|
|
519877105c | |
|
|
18d003f392 | |
|
|
97f1dd6312 | |
|
|
7a34bedbb6 | |
|
|
4cf2ac29ee | |
|
|
acf82dc323 | |
|
|
4592dc7943 | |
|
|
42bf9f990b | |
|
|
752e4da999 | |
|
|
d517d1bdaf | |
|
|
932db4e55c | |
|
|
323ba4f0ac | |
|
|
4541cf8129 | |
|
|
0160c7591d | |
|
|
04cd32e0eb | |
|
|
467c0ea8b5 | |
|
|
bf6429dc76 | |
|
|
2713517456 |
|
|
@ -56,3 +56,16 @@ app.*.map.json
|
||||||
/jj_aria2/
|
/jj_aria2/
|
||||||
*.log
|
*.log
|
||||||
/android/local.properties
|
/android/local.properties
|
||||||
|
|
||||||
|
# 내장 Chrome: 빌드할 때 내려받는 CEF 배포본 (약 330MB)
|
||||||
|
/packages/webview_cef/third/cef/
|
||||||
|
|
||||||
|
# Android 빌드 중간 파일
|
||||||
|
/android/build/
|
||||||
|
/packages/*/android/.cxx/
|
||||||
|
/packages/*/android/build/
|
||||||
|
|
||||||
|
# 플러그인 사본 안에 IDE 가 만든 Gradle 캐시
|
||||||
|
packages/*/android/.gradle/
|
||||||
|
# 플러그인 사본에서 pub get 을 하면 생기는 잠금 파일 (앱의 pubspec.lock 이 버전을 정함)
|
||||||
|
packages/*/pubspec.lock
|
||||||
|
|
|
||||||
12
README.md
|
|
@ -24,7 +24,7 @@
|
||||||
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
|
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
|
||||||
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
|
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
|
||||||
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
|
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
|
||||||
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
|
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유, 다른 언어 페이지를 화면 언어로 자동 번역 (Google 번역), JavaScript 끄기 |
|
||||||
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
|
||||||
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
|
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
|
||||||
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
|
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
|
||||||
|
|
@ -76,10 +76,13 @@ git clone <이 저장소>
|
||||||
cd app
|
cd app
|
||||||
powershell -File tool\fetch_ffmpeg.ps1 # third_party\ffmpeg\windows 에 FFmpeg 준비
|
powershell -File tool\fetch_ffmpeg.ps1 # third_party\ffmpeg\windows 에 FFmpeg 준비
|
||||||
powershell -File tool\fetch_tools.ps1 # third_party\tools\windows 에 yt-dlp · aria2 · Deno 준비
|
powershell -File tool\fetch_tools.ps1 # third_party\tools\windows 에 yt-dlp · aria2 · Deno 준비
|
||||||
|
powershell -File tool\fetch_rsync.ps1 # third_party\rsync\windows 에 rsync (MSYS2, SHA256 고정) 준비
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter build windows --release # build\windows\x64\runner\Release
|
flutter build windows --release # build\windows\x64\runner\Release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Android 의 rsync: Git Bash 에서 `sh tool/build_rsync_android.sh` (Android NDK 로 rsync 3.4.1 소스를 빌드해 third_party/rsync/android 에 둠, APK 에 들어감)
|
||||||
|
|
||||||
테스트: `flutter test` (AI 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀)
|
테스트: `flutter test` (AI 모델 · 인터넷이 필요한 테스트는 자동으로 건너뜀)
|
||||||
|
|
||||||
### 구조
|
### 구조
|
||||||
|
|
@ -101,9 +104,10 @@ Android 이식 시 `platform/android/` 구현과 `services/platform_services.dar
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|
||||||
- JJ_MKVMaker 소스 코드: [MIT](LICENSE)
|
- JJ_MKVMaker 소스 코드: [MIT](LICENSE)
|
||||||
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt)
|
- 포함 · 사용하는 구성 요소: [THIRD_PARTY_NOTICES.txt](assets/licenses/THIRD_PARTY_NOTICES.txt) (라이선스 원문: [assets/licenses](assets/licenses))
|
||||||
- FFmpeg (GPLv3) · aria2 (GPLv2) 는 수정하지 않은 별도 실행 파일로 함께 제공
|
- FFmpeg · yt-dlp 실행 파일 · rsync (GPLv3+) · aria2 (GPLv2+) 는 수정하지 않은 별도 실행 파일로 함께 제공
|
||||||
- libmpv (LGPL), whisper.cpp · ONNX Runtime (MIT), yt-dlp (Unlicense), Deno (MIT)
|
- libmpv (LGPL), PDFium (BSD), whisper.cpp · ONNX Runtime · stable-diffusion.cpp (MIT), Deno (MIT)
|
||||||
|
- **Android 판은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync) 를 포함하며, 대응 소스는 같은 판 번호의 태그 (예: v1.2.3) 입니다.**
|
||||||
- **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다
|
- **NLLB-200 번역 모델은 CC-BY-NC 4.0 (비상업적 이용만)** — 이 프로그램은 무료 배포이며, 모델은 사용자가 직접 내려받습니다
|
||||||
|
|
||||||
## 주의
|
## 주의
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
이 프로그램은 아래 오픈 소스 구성 요소를 포함하거나 내려받아 사용합니다.
|
|
||||||
각 구성 요소는 해당 라이선스를 따릅니다. 라이선스 원문은 같은 폴더(또는 ffmpeg\, tools\)의
|
|
||||||
*_LICENSE*, *_COPYING* 파일과 프로그램 안의 "환경 설정 > 라이선스" 화면에서 볼 수 있습니다.
|
|
||||||
|
|
||||||
[프로그램에 연결된 라이브러리]
|
|
||||||
|
|
||||||
- Flutter / Dart BSD 3-Clause https://github.com/flutter/flutter
|
|
||||||
- media_kit, media_kit_video MIT https://github.com/media-kit/media-kit
|
|
||||||
- libmpv (libmpv-2.dll) LGPL-2.1+ (-Dgpl=false 빌드)
|
|
||||||
https://github.com/mpv-player/mpv
|
|
||||||
빌드: https://github.com/media-kit/libmpv-win32-video-build
|
|
||||||
· libmpv 안의 FFmpeg LGPL-3.0 (--enable-version3, GPL 옵션 없음)
|
|
||||||
· libass, libplacebo 등 ISC / LGPL-2.1+
|
|
||||||
libmpv-2.dll 은 별도 파일이며, 같은 인터페이스의 다른 빌드로 바꿔 쓸 수 있습니다.
|
|
||||||
- ANGLE (libEGL.dll, libGLESv2.dll) BSD 3-Clause https://chromium.googlesource.com/angle/angle
|
|
||||||
- whisper.cpp / ggml MIT https://github.com/ggml-org/whisper.cpp
|
|
||||||
- whisper_ggml (JJ_MKVMaker 수정본) MIT https://pub.dev/packages/whisper_ggml
|
|
||||||
수정 내용: app\packages\whisper_ggml\JJ_PATCH.md
|
|
||||||
- ONNX Runtime (onnxruntime.dll) MIT https://github.com/microsoft/onnxruntime
|
|
||||||
- onnxruntime (Dart) MIT https://pub.dev/packages/onnxruntime
|
|
||||||
- window_manager, tray_manager, hotkey_manager, screen_retriever, nativeapi
|
|
||||||
MIT https://github.com/leanflutter
|
|
||||||
- flutter_inappwebview Apache-2.0 https://pub.dev/packages/flutter_inappwebview
|
|
||||||
- Microsoft Edge WebView2 SDK (WebView2Loader.dll)
|
|
||||||
BSD 3-Clause (Microsoft) https://www.nuget.org/packages/Microsoft.Web.WebView2
|
|
||||||
앱 안 브라우저는 Windows 에 설치된 Edge WebView2 런타임을 사용합니다 (Windows 11 기본 포함).
|
|
||||||
- desktop_drop Apache-2.0 https://pub.dev/packages/desktop_drop
|
|
||||||
- file_picker MIT https://pub.dev/packages/file_picker
|
|
||||||
- charset Apache-2.0 https://pub.dev/packages/charset
|
|
||||||
- cp949_codec BSD https://pub.dev/packages/cp949_codec
|
|
||||||
- Microsoft Visual C++ 런타임 (msvcp140.dll, vcruntime140*.dll)
|
|
||||||
Microsoft 재배포 가능 파일 (Visual Studio 라이선스의 Distributable Code)
|
|
||||||
- 그 밖의 Dart 패키지 라이선스: 프로그램의 "환경 설정 > 라이선스" 화면
|
|
||||||
|
|
||||||
[별도 실행 파일로 함께 제공 - 프로그램과 결합되지 않고 명령줄로 실행]
|
|
||||||
|
|
||||||
- FFmpeg (ffmpeg\ffmpeg.exe) GPL-3.0 https://ffmpeg.org
|
|
||||||
빌드: gyan.dev "release essentials" https://www.gyan.dev/ffmpeg/builds/
|
|
||||||
소스: https://ffmpeg.org/download.html 및 위 빌드 페이지의 소스 링크
|
|
||||||
원문: ffmpeg\FFMPEG_LICENSE.txt
|
|
||||||
- aria2 (tools\aria2c.exe) GPL-2.0-or-later https://github.com/aria2/aria2
|
|
||||||
소스: https://github.com/aria2/aria2/releases/tag/release-1.37.0
|
|
||||||
원문: tools\ARIA2_COPYING.txt
|
|
||||||
- yt-dlp (tools\yt-dlp.exe) Unlicense (퍼블릭 도메인) https://github.com/yt-dlp/yt-dlp
|
|
||||||
원문: tools\YT-DLP_LICENSE.txt
|
|
||||||
- Deno (tools\deno.exe) MIT https://github.com/denoland/deno
|
|
||||||
원문: tools\DENO_LICENSE.txt
|
|
||||||
|
|
||||||
위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
|
|
||||||
|
|
||||||
[처음 사용할 때 내려받는 AI 모델]
|
|
||||||
|
|
||||||
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp
|
|
||||||
- NLLB-200 distilled 600M (ONNX) CC-BY-NC 4.0 (비상업적 이용만 허용)
|
|
||||||
https://huggingface.co/facebook/nllb-200-distilled-600M
|
|
||||||
변환본: https://huggingface.co/Xenova/nllb-200-distilled-600M
|
|
||||||
※ NLLB 번역 모델은 상업적 용도로 쓸 수 없습니다. 판매·유료 서비스에 쓰려면
|
|
||||||
상업 이용이 가능한 번역 모델로 바꿔야 합니다.
|
|
||||||
|
|
||||||
[인터넷 서비스]
|
|
||||||
|
|
||||||
- OpenSubtitles.com REST API: 사용자가 발급받은 API 키로 이용하며 OpenSubtitles 이용 약관을 따릅니다.
|
|
||||||
- YouTube 등에서 받은 영상, 토렌트로 받은 파일의 저작권 책임은 이용자에게 있습니다.
|
|
||||||
|
|
@ -14,6 +14,11 @@ analyzer:
|
||||||
- build/**
|
- build/**
|
||||||
- android/**
|
- android/**
|
||||||
- windows/**
|
- windows/**
|
||||||
|
- packages/webview_cef/**
|
||||||
|
- packages/flutter_inappwebview_android/**
|
||||||
|
- packages/ffmpeg_kit_flutter_new_min_gpl/** # 고친 외부 부품 (원본 코드 그대로)
|
||||||
|
- packages/flutter_inappwebview_windows/** # 고친 외부 부품 (Tor 화면 다운로드 막기 · 프록시 인증, JJ_PATCH.md)
|
||||||
|
- packages/whisper_ggml/** # 고친 외부 부품 (자기 analysis_options 가 flutter_lints 를 찾지 못함)
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 배포 서명 키 (android/key.properties, 저장소에 올리지 않음). 없으면 디버그 키로 서명.
|
||||||
|
val keyProps = Properties().apply {
|
||||||
|
val f = rootProject.file("key.properties")
|
||||||
|
if (f.exists()) f.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.jj.jj_mkvmaker"
|
namespace = "com.jj.jj_mkvmaker"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
// whisper_ggml 이 NDK 29 를 요구 (더 낮은 버전용 코드와 호환)
|
||||||
|
ndkVersion = "29.0.13113456"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
|
@ -15,25 +24,63 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.jj.jj_mkvmaker"
|
applicationId = "com.jj.jj_mkvmaker"
|
||||||
// You can update the following values to match your application needs.
|
// ffmpeg-kit 은 Android 7.0 (API 24) 이상
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
minSdk = maxOf(24, flutter.minSdkVersion)
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
// 버전 "년.월.일+순번" 의 순번은 날마다 1 부터 다시 시작하므로 그대로 쓰면 다음 날 판이 "더 낮은 버전" 으로
|
||||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
// 설치가 거부된다. 날짜를 앞에 붙여 늘 커지게 한다: 2026.10.2+1 → 261002001
|
||||||
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
versionCode = flutter.versionName.split(".").let { (y, m, d) ->
|
||||||
// flag during build.
|
(y.toInt() % 100) * 10_000_000 + m.toInt() * 100_000 + d.toInt() * 1_000 + flutter.versionCode
|
||||||
versionCode = flutter.versionCode
|
}
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
// 요즘 휴대폰 · 태블릿 (64비트 ARM) 만. 플러그인이 다른 CPU 용 라이브러리를 넣어도 앱 본체가 없으면 실행되지 않으므로 뺀다
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keyProps.getProperty("storeFile") != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keyProps.getProperty("storeFile"))
|
||||||
|
storePassword = keyProps.getProperty("storePassword")
|
||||||
|
keyAlias = keyProps.getProperty("keyAlias")
|
||||||
|
keyPassword = keyProps.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rsync (librsync.so 라는 이름의 실행 파일): tool/build_rsync_android.sh 로 third_party/rsync/android 에 준비.
|
||||||
|
// 네이티브 라이브러리 폴더에 풀려 실행할 수 있다 (앱 데이터 폴더의 파일은 Android 10+ 에서 실행 불가)
|
||||||
|
sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android")
|
||||||
|
// 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh
|
||||||
|
sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android")
|
||||||
|
// Tor (libtor.so 라는 이름의 실행 파일): 공식 Tor Expert Bundle (Android aarch64) 의 libTor.so, tool/fetch_tor_android.ps1 (판 · SHA-256 고정)
|
||||||
|
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
|
||||||
|
// 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
|
||||||
|
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android")
|
||||||
|
// 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
|
||||||
|
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet()
|
||||||
|
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android").forEach { dir ->
|
||||||
|
fileTree(dir).matching { include("**/*.so") }.forEach { so ->
|
||||||
|
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드)
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
// 64비트 ARM 만 (위 abiFilters 와 같은 뜻, 플러그인 라이브러리까지 확실히 빼기)
|
||||||
|
excludes += setOf("lib/x86_64/**", "lib/x86/**", "lib/armeabi-v7a/**")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,3 +94,17 @@ kotlin {
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다운로드 (앱 안 브라우저 · 다운로드 목록): Android 용 yt-dlp (Python 포함) · ffmpeg (영상 · 음성 합치기) · aria2c (토렌트)
|
||||||
|
// 실행 파일을 앱에 넣고 처음 켤 때 풀어 둔다. 실행은 Dart (YtDlpBackend · Aria2Backend) 가 Windows 와 같은 방식으로 한다.
|
||||||
|
dependencies {
|
||||||
|
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
|
||||||
|
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
|
||||||
|
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
|
||||||
|
// 업데이트 APK 를 설치 화면에 넘기는 FileProvider
|
||||||
|
implementation("androidx.core:core:1.16.0")
|
||||||
|
// Tor 화면: 웹 뷰 기능 확인 · 전용 프로필 지우기 (flutter_inappwebview_android 와 같은 판)
|
||||||
|
implementation("androidx.webkit:webkit:1.12.0")
|
||||||
|
// 88: Android SMB (SMB2 · SMB3, SMB1 없음) - Apache-2.0. 고지: THIRD_PARTY_NOTICES_ANDROID · 라이선스 화면
|
||||||
|
implementation("com.hierynomus:smbj:0.15.0")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# youtubedl-android (Android 다운로드): 코드 축소(R8)가 Jackson · 압축 풀기 클래스를 지우거나 이름을 바꾸면
|
||||||
|
# YoutubeDL 객체를 만들 때 ExceptionInInitializerError 가 난다 (youtubedl-android README 의 권장 규칙)
|
||||||
|
-keep class com.yausername.** { *; }
|
||||||
|
-keep class com.fasterxml.jackson.** { *; }
|
||||||
|
-keep class org.apache.commons.compress.archivers.zip.** { *; }
|
||||||
|
-dontwarn com.fasterxml.jackson.**
|
||||||
|
-dontwarn org.apache.commons.**
|
||||||
|
# 88: smbj (SMB) - 메시지 · 보안 제공자를 이름으로 찾는다 (축소가 지우면 연결 · 로그인이 실패)
|
||||||
|
-keep class com.hierynomus.** { *; }
|
||||||
|
-keep class net.engio.mbassy.** { *; }
|
||||||
|
-keep class org.bouncycastle.** { *; }
|
||||||
|
-dontwarn com.hierynomus.**
|
||||||
|
-dontwarn net.engio.mbassy.**
|
||||||
|
-dontwarn org.bouncycastle.**
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
|
|
@ -4,4 +4,6 @@
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
|
||||||
|
<application android:usesCleartextTraffic="true"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,38 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- 28 APK 점검 · 관리자 판단 (10/19): Wi-Fi 공유 · SMB 찾기가 망 종류 (Wi-Fi · 유선 / 모바일 데이터 · VPN) 를 알기 위해. 일반 권한 (묻지 않음) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
|
<!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
|
<!-- 예전 버전으로 되돌릴 때 이 앱을 지우는 확인 창 (Android 는 낮은 버전을 위에 설치하지 못함) -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||||
<application
|
<application
|
||||||
android:label="jj_mkvmaker"
|
android:label="JJ_MKVMaker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
|
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
|
||||||
|
<property
|
||||||
|
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
|
||||||
|
android:value="true" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
|
@ -20,11 +46,107 @@
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
|
<!-- 앱 목록 · 홈 화면 아이콘은 아래 activity-alias (환경 설정 > 일반 > 앱 아이콘 에서 고른 것 하나만 켬) -->
|
||||||
|
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="content"/>
|
||||||
|
<data android:scheme="file"/>
|
||||||
|
<data android:mimeType="video/*"/>
|
||||||
|
</intent-filter>
|
||||||
|
<!-- 공유 메뉴: 동영상 하나 · 여러 개 -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND"/>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="video/*"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- 아이콘이 여는 중계 화면 (MainActivity 를 열고 바로 닫힘: 아이콘을 바꿔도 앱 화면이 닫히지 않게) -->
|
||||||
|
<activity
|
||||||
|
android:name=".LauncherActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||||
|
<!-- 앱 아이콘 (고를 수 있는 것마다 하나, 처음에는 노랑만 켜짐) -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".IconYellow"
|
||||||
|
android:targetActivity=".LauncherActivity"
|
||||||
|
android:icon="@mipmap/ic_launcher_yellow"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".IconBlack"
|
||||||
|
android:targetActivity=".LauncherActivity"
|
||||||
|
android:icon="@mipmap/ic_launcher_black"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".IconFilmJj"
|
||||||
|
android:targetActivity=".LauncherActivity"
|
||||||
|
android:icon="@mipmap/ic_launcher_film_jj"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".IconFilm"
|
||||||
|
android:targetActivity=".LauncherActivity"
|
||||||
|
android:icon="@mipmap/ic_launcher_film"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".IconBlue"
|
||||||
|
android:targetActivity=".LauncherActivity"
|
||||||
|
android:icon="@mipmap/ic_launcher_blue"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<!-- 업데이트 APK 를 설치 화면에 넘기기 위한 파일 공유 (cache/updates 만) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.files"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/jj_file_paths"/>
|
||||||
|
</provider>
|
||||||
|
<!-- 작업 알림 서비스 (28 APK 점검 · 관리자 지시 10/19): specialUse 설명은 실제로 하는 일 모두를 적는다
|
||||||
|
(실시간 동기화 · 동영상 변환 · AI 자막 · 다운로드 · 복사/이동 · 원격 서버 전송 · Wi-Fi 공유) -->
|
||||||
|
<service
|
||||||
|
android:name=".KeepAliveService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync|specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/>
|
||||||
|
</service>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
package com.jj.jj_mkvmaker
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
|
||||||
|
/// 다운로드 · MKV 만들기 · AI 자막 · 실시간 동기화 (백그라운드로 실행) 가 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
|
||||||
|
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
|
||||||
|
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
|
||||||
|
class KeepAliveService : Service() {
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val text = intent?.getStringExtra(EXTRA_TEXT) ?: ""
|
||||||
|
val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1
|
||||||
|
val icon = intent?.getStringExtra(EXTRA_ICON) ?: ""
|
||||||
|
val n = notification(this, text, progress, icon)
|
||||||
|
running = true
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// 실시간 동기화는 앱이 켜져 있는 내내 지켜봐야 하므로 하루 6시간 제한이 있는 dataSync 대신 specialUse (Android 14+)
|
||||||
|
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, n)
|
||||||
|
}
|
||||||
|
if (wakeLock == null) {
|
||||||
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs")
|
||||||
|
.apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(RENEW_MS * 2)
|
||||||
|
}
|
||||||
|
// 22: 6시간이 지나면 잠금이 풀려 화면을 끈 채 걸어 둔 동기화 · 긴 작업이 조용히 멈출 수 있었다 → 작업 중에는 늘 다시 건다
|
||||||
|
renewer.postDelayed(renew, RENEW_MS)
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속)
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val renewer = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private val renew = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
wakeLock?.let { if (running) it.acquire(RENEW_MS * 2) }
|
||||||
|
if (running) renewer.postDelayed(this, RENEW_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최근 앱에서 밀어 닫음: "백그라운드로 실행" 이 꺼져 있으면 작업이 멈추므로 알림을 남기고 서비스도 끝낸다 (21)
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
if (!MainActivity.background) {
|
||||||
|
notifyStopped(this)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
renewer.removeCallbacks(renew)
|
||||||
|
running = false
|
||||||
|
wakeLock?.let { if (it.isHeld) it.release() }
|
||||||
|
wakeLock = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "jobs"
|
||||||
|
const val NOTIFICATION_ID = 1
|
||||||
|
const val STOPPED_ID = 2
|
||||||
|
const val DONE_ID = 3
|
||||||
|
|
||||||
|
const val DONE_CHANNEL_ID = "done"
|
||||||
|
|
||||||
|
/// 57: 알림 글 · 채널 이름을 앱 화면 언어로 (앱이 고른 언어라 Android 의 values-xx 로는 맞출 수 없다).
|
||||||
|
/// 앱 (Dart) 이 언어를 바꿀 때마다 보내 오고, 다음에 켤 때도 쓰게 저장해 둔다. 없으면 한국어.
|
||||||
|
private const val PREFS = "jj_notify_texts"
|
||||||
|
private val defaults = mapOf(
|
||||||
|
"working" to "JJ_MKVMaker 작업 중",
|
||||||
|
"stoppedTitle" to "JJ_MKVMaker 작업이 멈췄습니다",
|
||||||
|
"stoppedText" to "앱을 닫아 진행 중이던 작업이 멈췄습니다. 닫아도 계속하려면 환경 설정에서 '백그라운드로 실행' 을 켜세요.",
|
||||||
|
"stoppedLong" to "앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.",
|
||||||
|
"jobsChannel" to "작업 진행",
|
||||||
|
"jobsChannelDesc" to "다운로드 · MKV 만들기 · AI 자막 · 동기화 진행 상황",
|
||||||
|
"doneChannel" to "끝난 작업",
|
||||||
|
"doneChannelDesc" to "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun t(context: Context, key: String): String =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(key, null)?.takeIf { it.isNotEmpty() }
|
||||||
|
?: defaults.getValue(key)
|
||||||
|
|
||||||
|
/// 앱이 보낸 글을 저장하고, 이미 만든 채널은 이름 · 설명을 바꾼다 (같은 ID 로 다시 만들면 이름만 바뀐다)
|
||||||
|
fun setTexts(context: Context, texts: Map<String, String>) {
|
||||||
|
val e = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
for ((k, v) in texts) if (k in defaults) e.putString(k, v)
|
||||||
|
e.apply()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) createJobsChannel(context, nm, texts)
|
||||||
|
if (nm.getNotificationChannel(DONE_CHANNEL_ID) != null) createDoneChannel(context, nm, texts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pick(context: Context, texts: Map<String, String>?, key: String) =
|
||||||
|
texts?.get(key)?.takeIf { it.isNotEmpty() } ?: t(context, key)
|
||||||
|
|
||||||
|
private fun createJobsChannel(context: Context, nm: NotificationManager, texts: Map<String, String>? = null) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, pick(context, texts, "jobsChannel"), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = pick(context, texts, "jobsChannelDesc")
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDoneChannel(context: Context, nm: NotificationManager, texts: Map<String, String>? = null) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(DONE_CHANNEL_ID, pick(context, texts, "doneChannel"), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = pick(context, texts, "doneChannelDesc")
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로.
|
||||||
|
/// 진행 알림 채널 (조용히, 낮은 중요도) 과 따로 두어 알림 창의 "조용한 알림" 칸에 묻히지 않게 (보통 중요도, 소리 없음)
|
||||||
|
fun notifyDone(context: Context, title: String, text: String) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(DONE_CHANNEL_ID) == null) {
|
||||||
|
createDoneChannel(context, nm)
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context, 3,
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(MainActivity.ACTION_SHOW_JOBS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(context, DONE_CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") Notification.Builder(context)
|
||||||
|
}
|
||||||
|
b.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
nm.notify(DONE_ID, b.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 잠금을 다시 거는 간격 (1시간, 잠금은 그 두 배 동안)
|
||||||
|
const val RENEW_MS = 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/// 앱을 닫아 작업이 멈췄다는 알림 (누르면 앱을 다시 연다)
|
||||||
|
fun notifyStopped(context: Context) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context, 1, Intent(context, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(context, CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") Notification.Builder(context)
|
||||||
|
}
|
||||||
|
b.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setContentTitle(t(context, "stoppedTitle"))
|
||||||
|
.setContentText(t(context, "stoppedText"))
|
||||||
|
.setStyle(Notification.BigTextStyle().bigText(t(context, "stoppedLong")))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
nm.notify(STOPPED_ID, b.build())
|
||||||
|
}
|
||||||
|
const val EXTRA_TEXT = "text"
|
||||||
|
const val EXTRA_PROGRESS = "progress"
|
||||||
|
const val EXTRA_ICON = "icon"
|
||||||
|
|
||||||
|
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
|
||||||
|
@Volatile
|
||||||
|
var running = false
|
||||||
|
|
||||||
|
fun update(context: Context, text: String, progress: Int, icon: String) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(NOTIFICATION_ID, notification(context, text, progress, icon))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(context: Context, text: String, progress: Int, icon: String): Notification {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
createJobsChannel(context, nm)
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context, 0,
|
||||||
|
// 누르면 앱의 "작업 현황" 화면으로 (지금 하는 작업 · 진행률 · 대기열 · 다운로드)
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(MainActivity.ACTION_SHOW_JOBS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(context, CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") Notification.Builder(context)
|
||||||
|
}
|
||||||
|
// 다운로드 중이면 내려받기 아이콘, 그 밖 (실시간 동기화 · MKV · AI 자막) 은 좌우로 오가는 화살표
|
||||||
|
b.setSmallIcon(if (icon == "download") android.R.drawable.stat_sys_download else R.drawable.ic_stat_sync_anim)
|
||||||
|
.setContentTitle(t(context, "working"))
|
||||||
|
.setContentText(text)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
if (progress in 0..100) b.setProgress(100, progress, false)
|
||||||
|
return b.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.jj.jj_mkvmaker
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
/// 앱 목록 · 홈 화면 아이콘 (activity-alias) 이 여는 중계 화면: MainActivity 를 열고 바로 닫는다.
|
||||||
|
/// MainActivity 작업이 alias 로 시작되면 환경 설정에서 아이콘을 바꿀 때 (그 alias 를 끄면) 앱 화면이 닫히므로.
|
||||||
|
class LauncherActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_MAIN
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
})
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,649 @@
|
||||||
package com.jj.jj_mkvmaker
|
package com.jj.jj_mkvmaker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.webkit.ProfileStore
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import com.yausername.aria2c.Aria2c
|
||||||
|
import com.yausername.ffmpeg.FFmpeg
|
||||||
|
import com.yausername.youtubedl_android.YoutubeDL
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
companion object {
|
||||||
|
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
|
||||||
|
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
|
||||||
|
|
||||||
|
private const val ENGINE_ID = "main"
|
||||||
|
|
||||||
|
/// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기)
|
||||||
|
/// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠).
|
||||||
|
@Volatile
|
||||||
|
var background = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 앞 화면이 닫힌 뒤 살아 있던 엔진을 다시 붙였는지 (Dart 는 이미 돌고 있다)
|
||||||
|
private var reusedEngine = false
|
||||||
|
|
||||||
|
/// 엔진은 앱 (프로세스) 이 갖는다: 화면이 닫혀도 [background] 면 살려 두고, 다시 열면 그 엔진을 붙인다
|
||||||
|
override fun provideFlutterEngine(context: Context): FlutterEngine {
|
||||||
|
val cache = FlutterEngineCache.getInstance()
|
||||||
|
cache.get(ENGINE_ID)?.let {
|
||||||
|
reusedEngine = true
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
return FlutterEngine(context.applicationContext).also { cache.put(ENGINE_ID, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 백그라운드로 실행이 꺼져 있으면 지금처럼 화면과 함께 엔진도 끝낸다
|
||||||
|
override fun shouldDestroyEngineWithHost(): Boolean {
|
||||||
|
if (background) return false
|
||||||
|
FlutterEngineCache.getInstance().remove(ENGINE_ID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 첫 화면에서 ←: 백그라운드로 실행이면 앱을 끝내지 않고 뒤로 보낸다 (홈 버튼과 같음)
|
||||||
|
override fun popSystemNavigator(): Boolean {
|
||||||
|
if (!background) return false
|
||||||
|
moveTaskToBack(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channel: MethodChannel? = null
|
||||||
|
|
||||||
|
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
|
||||||
|
private var pendingShowJobs = false
|
||||||
|
|
||||||
|
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
|
||||||
|
private var pendingOpen: List<Map<String, String?>> = emptyList()
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
pendingOpen = openedFiles(intent)
|
||||||
|
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
|
||||||
|
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
|
||||||
|
channel = ch
|
||||||
|
// 88: SMB (smbj) - 탐색기의 SMB 서버 탭
|
||||||
|
SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger)
|
||||||
|
if (reusedEngine) {
|
||||||
|
// Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다
|
||||||
|
if (pendingShowJobs) ch.invokeMethod("showJobs", null)
|
||||||
|
if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen)
|
||||||
|
pendingShowJobs = false
|
||||||
|
pendingOpen = emptyList()
|
||||||
|
}
|
||||||
|
ch.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"secretReadAll" -> {
|
||||||
|
try {
|
||||||
|
result.success(SecretBox.readAll(applicationContext))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("secret", e.toString(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"secretWrite" -> {
|
||||||
|
try {
|
||||||
|
SecretBox.write(applicationContext, call.argument<String>("key")!!, call.argument<String>("value")!!)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("secret", e.toString(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"secretDelete" -> {
|
||||||
|
SecretBox.delete(applicationContext, call.argument<String>("key")!!)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"moveToBack" -> {
|
||||||
|
// 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속)
|
||||||
|
moveTaskToBack(true)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setBackground" -> {
|
||||||
|
background = call.argument<Boolean>("on") ?: false
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"exitApp" -> {
|
||||||
|
// [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다
|
||||||
|
result.success(null)
|
||||||
|
background = false
|
||||||
|
stopService(Intent(applicationContext, KeepAliveService::class.java))
|
||||||
|
FlutterEngineCache.getInstance().remove(ENGINE_ID)
|
||||||
|
finishAndRemoveTask()
|
||||||
|
android.os.Handler(mainLooper).postDelayed({
|
||||||
|
android.os.Process.killProcess(android.os.Process.myPid())
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
"takeOpenedFiles" -> {
|
||||||
|
result.success(pendingOpen)
|
||||||
|
pendingOpen = emptyList()
|
||||||
|
}
|
||||||
|
"takeShowJobs" -> {
|
||||||
|
result.success(pendingShowJobs)
|
||||||
|
pendingShowJobs = false
|
||||||
|
}
|
||||||
|
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
|
||||||
|
"requestAllFilesAccess" -> {
|
||||||
|
requestAllFilesAccess()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
|
||||||
|
// 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳
|
||||||
|
"nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir)
|
||||||
|
// Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음)
|
||||||
|
"torWebSupport" -> result.success(mapOf(
|
||||||
|
"proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE),
|
||||||
|
"multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE),
|
||||||
|
"documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT),
|
||||||
|
))
|
||||||
|
// Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시)
|
||||||
|
"torProfileClear" -> result.success(clearWebProfile(call.argument<String>("name") ?: "jj_tor"))
|
||||||
|
"storageVolumes" -> result.success(storageVolumes())
|
||||||
|
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
|
||||||
|
"installApk" -> try {
|
||||||
|
installApk(call.argument<String>("path") ?: "")
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: InstallPermissionNeededException) {
|
||||||
|
// 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다
|
||||||
|
result.error("PERMISSION", e.message ?: e.toString(), null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다)
|
||||||
|
result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null)
|
||||||
|
}
|
||||||
|
// 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지
|
||||||
|
"canInstallPackages" -> result.success(
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
|
||||||
|
)
|
||||||
|
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||||
|
// 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다
|
||||||
|
"uninstallSelf" -> {
|
||||||
|
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
|
||||||
|
"netKinds" -> result.success(netKinds())
|
||||||
|
"diskSpace" -> result.success(
|
||||||
|
try {
|
||||||
|
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
||||||
|
listOf(st.availableBytes, st.totalBytes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"openWith" -> result.success(
|
||||||
|
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
|
||||||
|
)
|
||||||
|
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
|
||||||
|
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
|
||||||
|
"keepAlive" -> {
|
||||||
|
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
|
||||||
|
call.argument<String>("icon") ?: "")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"notificationTexts" -> {
|
||||||
|
// 57: 알림 글 · 채널 이름 (앱 화면 언어)
|
||||||
|
val m = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||||
|
KeepAliveService.setTexts(applicationContext,
|
||||||
|
m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap())
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"notifyDone" -> {
|
||||||
|
KeepAliveService.notifyDone(applicationContext, call.argument<String>("title") ?: "",
|
||||||
|
call.argument<String>("text") ?: "")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopKeepAlive" -> {
|
||||||
|
stopService(Intent(applicationContext, KeepAliveService::class.java))
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
|
||||||
|
"downloadToolsUpdate" -> inBackground(result) {
|
||||||
|
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
|
||||||
|
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
|
||||||
|
private fun installApk(path: String) {
|
||||||
|
val src = File(path)
|
||||||
|
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
|
||||||
|
}
|
||||||
|
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
|
||||||
|
val dir = File(cacheDir, "updates").apply { mkdirs() }
|
||||||
|
val apk = File(dir, "update.apk")
|
||||||
|
if (src.absolutePath != apk.absolutePath) {
|
||||||
|
apk.delete()
|
||||||
|
if (!src.renameTo(apk)) {
|
||||||
|
src.copyTo(apk, overwrite = true)
|
||||||
|
src.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk)
|
||||||
|
startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별)
|
||||||
|
class InstallPermissionNeededException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
if (intent.action == ACTION_SHOW_JOBS) {
|
||||||
|
channel?.invokeMethod("showJobs", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val files = openedFiles(intent)
|
||||||
|
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}]
|
||||||
|
private fun openedFiles(intent: Intent?): List<Map<String, String?>> {
|
||||||
|
if (intent == null) return emptyList()
|
||||||
|
val uris = mutableListOf<Uri>()
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) }
|
||||||
|
Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) }
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) }
|
||||||
|
}
|
||||||
|
return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun intentStream(i: Intent): Uri? =
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else i.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun intentStreams(i: Intent): List<Uri>? =
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||||
|
|
||||||
|
private fun displayName(uri: Uri): String? = try {
|
||||||
|
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||||
|
if (it.moveToFirst()) it.getString(0) else null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
} ?: uri.lastPathSegment?.substringAfterLast('/')
|
||||||
|
|
||||||
|
/// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null.
|
||||||
|
private fun realPath(uri: Uri): String? {
|
||||||
|
fun ok(path: String?) = path != null && File(path).isFile
|
||||||
|
if (uri.scheme == "file") return uri.path?.takeIf { ok(it) }
|
||||||
|
if (uri.scheme != "content") return null
|
||||||
|
// 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4)
|
||||||
|
try {
|
||||||
|
if (android.provider.DocumentsContract.isDocumentUri(this, uri) &&
|
||||||
|
uri.authority == "com.android.externalstorage.documents"
|
||||||
|
) {
|
||||||
|
val id = android.provider.DocumentsContract.getDocumentId(uri)
|
||||||
|
val vol = id.substringBefore(':')
|
||||||
|
val rel = id.substringAfter(':', "")
|
||||||
|
val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol"
|
||||||
|
val path = "$base/$rel"
|
||||||
|
if (ok(path)) return path
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
// 2. 미디어 저장소 등이 알려 주는 경로 (_data)
|
||||||
|
try {
|
||||||
|
contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val path = it.getString(0)
|
||||||
|
if (ok(path)) return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
// 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서)
|
||||||
|
// 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만)
|
||||||
|
val size = try {
|
||||||
|
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use {
|
||||||
|
if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L
|
||||||
|
} ?: -1L
|
||||||
|
} catch (e: Exception) {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() }
|
||||||
|
val roots = storageVolumes().map { it["path"] as String }
|
||||||
|
for (i in segs.indices) {
|
||||||
|
val rest = segs.drop(i).joinToString("/")
|
||||||
|
for (root in roots) {
|
||||||
|
val f = File(root, rest)
|
||||||
|
if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath
|
||||||
|
}
|
||||||
|
// 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…)
|
||||||
|
val abs = "/$rest"
|
||||||
|
if (abs.startsWith("/storage/") && ok(abs)) return abs
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
|
||||||
|
private fun storageVolumes(): List<Map<String, Any>> {
|
||||||
|
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager
|
||||||
|
val out = mutableListOf<Map<String, Any>>()
|
||||||
|
for (v in sm.storageVolumes) {
|
||||||
|
if (v.state != Environment.MEDIA_MOUNTED) continue
|
||||||
|
val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
v.directory
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
v.javaClass.getMethod("getPathFile").invoke(v) as File?
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dir == null) continue
|
||||||
|
out.add(
|
||||||
|
mapOf(
|
||||||
|
"path" to dir.absolutePath,
|
||||||
|
"label" to (v.getDescription(this) ?: dir.name),
|
||||||
|
"removable" to v.isRemovable,
|
||||||
|
"primary" to v.isPrimary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 웹 주소를 기기의 기본 브라우저로
|
||||||
|
private fun openUrl(url: String): Boolean = try {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false.
|
||||||
|
/// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로)
|
||||||
|
private fun setAppIcon(id: String): Boolean {
|
||||||
|
val ids = listOf("yellow", "black", "film_jj", "film", "blue")
|
||||||
|
if (id !in ids) return false
|
||||||
|
val pm = packageManager
|
||||||
|
for (i in ids) {
|
||||||
|
val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) }
|
||||||
|
val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
val cn = android.content.ComponentName(this, name)
|
||||||
|
if (pm.getComponentEnabledSetting(cn) != state) {
|
||||||
|
pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창).
|
||||||
|
/// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml).
|
||||||
|
private fun openWith(path: String, choose: Boolean): Boolean {
|
||||||
|
val f = File(path)
|
||||||
|
if (!f.isFile) return false
|
||||||
|
val uri = try {
|
||||||
|
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*"
|
||||||
|
val view = Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, mime)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
fun chooser(): Intent = Intent.createChooser(view, null)
|
||||||
|
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
return try {
|
||||||
|
startActivity(if (choose) chooser() else view)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
startActivity(chooser())
|
||||||
|
true
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌)
|
||||||
|
private fun shareFiles(paths: List<String>): Boolean {
|
||||||
|
val uris = ArrayList<android.net.Uri>()
|
||||||
|
val mimes = mutableSetOf<String>()
|
||||||
|
for (p in paths) {
|
||||||
|
val f = File(p)
|
||||||
|
if (!f.isFile) continue
|
||||||
|
uris.add(
|
||||||
|
try {
|
||||||
|
androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*")
|
||||||
|
}
|
||||||
|
if (uris.isEmpty()) return false
|
||||||
|
// 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */*
|
||||||
|
val tops = mimes.map { it.substringBefore('/') }.toSet()
|
||||||
|
val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*"
|
||||||
|
val send = if (uris.size == 1) {
|
||||||
|
Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
|
||||||
|
} else {
|
||||||
|
Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
}
|
||||||
|
send.type = mime
|
||||||
|
// 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다)
|
||||||
|
val clip = android.content.ClipData.newRawUri(null, uris[0])
|
||||||
|
for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i]))
|
||||||
|
send.clipData = clip
|
||||||
|
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
return try {
|
||||||
|
startActivity(
|
||||||
|
Intent.createChooser(send, null)
|
||||||
|
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java)))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openFolder(path: String): Boolean {
|
||||||
|
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
|
||||||
|
val root = Environment.getExternalStorageDirectory().path
|
||||||
|
val (volumeId, rel) = when {
|
||||||
|
path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/')
|
||||||
|
path.startsWith("/storage/") -> {
|
||||||
|
val rest = path.removePrefix("/storage/")
|
||||||
|
rest.substringBefore('/') to rest.substringAfter('/', "").trim('/')
|
||||||
|
}
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
// 1. 삼성 "내 파일": 그 폴더로 바로 연다
|
||||||
|
try {
|
||||||
|
startActivity(
|
||||||
|
Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES")
|
||||||
|
.setPackage("com.sec.android.app.myfiles")
|
||||||
|
.putExtra("samsung.myfiles.intent.extra.START_PATH", path)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
// 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다
|
||||||
|
val uri = android.provider.DocumentsContract.buildDocumentUri(
|
||||||
|
"com.android.externalstorage.documents", "$volumeId:$rel"
|
||||||
|
)
|
||||||
|
for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) {
|
||||||
|
try {
|
||||||
|
startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR)
|
||||||
|
.apply { if (pkg != null) setPackage(pkg) }
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
|
||||||
|
private var askedNotifications = false
|
||||||
|
|
||||||
|
private fun keepAlive(text: String, progress: Int, icon: String) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed &&
|
||||||
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
askedNotifications = true
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
|
||||||
|
}
|
||||||
|
if (KeepAliveService.running) {
|
||||||
|
KeepAliveService.update(applicationContext, text, progress, icon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val i = Intent(applicationContext, KeepAliveService::class.java)
|
||||||
|
.putExtra(KeepAliveService.EXTRA_TEXT, text)
|
||||||
|
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
|
||||||
|
.putExtra(KeepAliveService.EXTRA_ICON, icon)
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i)
|
||||||
|
else applicationContext.startService(i)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
|
||||||
|
android.util.Log.w("jj_mkvmaker", "keepAlive", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
|
||||||
|
private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val value = work()
|
||||||
|
runOnUiThread { result.success(value) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
android.util.Log.e("jj_mkvmaker", "download tools", e)
|
||||||
|
val cause = generateSequence(e) { it.cause }.last()
|
||||||
|
runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다.
|
||||||
|
/// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다.
|
||||||
|
private fun downloadToolsInit(): Map<String, Any> {
|
||||||
|
YoutubeDL.init(applicationContext)
|
||||||
|
FFmpeg.init(applicationContext)
|
||||||
|
Aria2c.init(applicationContext)
|
||||||
|
val bin = applicationInfo.nativeLibraryDir
|
||||||
|
val base = File(noBackupFilesDir, YoutubeDL.baseName)
|
||||||
|
val packages = File(base, "packages")
|
||||||
|
val python = File(packages, "python").absolutePath
|
||||||
|
val ffmpeg = File(packages, "ffmpeg").absolutePath
|
||||||
|
val aria2c = File(packages, "aria2c").absolutePath
|
||||||
|
val cert = "$python/usr/etc/tls/cert.pem"
|
||||||
|
return mapOf(
|
||||||
|
"python" to "$bin/libpython.so",
|
||||||
|
"ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath,
|
||||||
|
"ffmpeg" to "$bin/libffmpeg.so",
|
||||||
|
"quickjs" to "$bin/libqjs.so",
|
||||||
|
"aria2c" to "$bin/libaria2c.so",
|
||||||
|
"cert" to cert,
|
||||||
|
"version" to (YoutubeDL.versionName(applicationContext) ?: ""),
|
||||||
|
"env" to mapOf(
|
||||||
|
"LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib",
|
||||||
|
"SSL_CERT_FILE" to cert,
|
||||||
|
"PATH" to (System.getenv("PATH") ?: "") + ":" + bin,
|
||||||
|
"PYTHONHOME" to "$python/usr",
|
||||||
|
"HOME" to "$python/usr",
|
||||||
|
"TMPDIR" to cacheDir.absolutePath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false
|
||||||
|
private fun clearWebProfile(name: String): Boolean {
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true
|
||||||
|
val store = ProfileStore.getInstance()
|
||||||
|
if (store.getProfile(name) == null) return true
|
||||||
|
return try {
|
||||||
|
store.deleteProfile(name)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
try {
|
||||||
|
val profile = store.getProfile(name)
|
||||||
|
profile?.cookieManager?.removeAllCookies(null)
|
||||||
|
profile?.webStorage?.deleteAllData()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAllFilesAccess(): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
Environment.isExternalStorageManager()
|
||||||
|
} else {
|
||||||
|
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAllFilesAccess() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName")))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestPermissions(
|
||||||
|
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun netKinds(): Map<String, String>? = try {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
val out = HashMap<String, String>()
|
||||||
|
for (n in cm.allNetworks) {
|
||||||
|
val caps = cm.getNetworkCapabilities(n) ?: continue
|
||||||
|
val lp = cm.getLinkProperties(n) ?: continue
|
||||||
|
val kind = when {
|
||||||
|
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
|
||||||
|
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||||
|
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||||
|
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
for (la in lp.linkAddresses) {
|
||||||
|
val a = la.address
|
||||||
|
if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |