diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 87bb955..05f2284 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -84,4 +84,6 @@ 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")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 093ad22..66f2eb5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,8 @@
+
+
+
+
+
+
result.success(Environment.getExternalStorageDirectory().path)
"openUrl" -> result.success(openUrl(call.argument("url") ?: ""))
+ "installApk" -> try {
+ installApk(call.argument("path") ?: "")
+ result.success(null)
+ } catch (e: Exception) {
+ result.error("INSTALL", e.message ?: e.toString(), null)
+ }
"openFolder" -> result.success(openFolder(call.argument("path") ?: ""))
"keepAlive" -> {
keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1)
@@ -46,6 +52,36 @@ class MainActivity : FlutterActivity() {
}
}
+ /// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
+ /// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용한 뒤 다시 [업데이트])
+ private fun installApk(path: String) {
+ val src = File(path)
+ if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
+ startActivity(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ throw IllegalStateException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
+ }
+ // FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
+ val dir = File(cacheDir, "updates").apply { mkdirs() }
+ val apk = File(dir, "update.apk")
+ if (src.absolutePath != apk.absolutePath) {
+ apk.delete()
+ if (!src.renameTo(apk)) {
+ src.copyTo(apk, overwrite = true)
+ src.delete()
+ }
+ }
+ val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk)
+ startActivity(
+ Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, "application/vnd.android.package-archive")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+
/// 웹 주소를 기기의 기본 브라우저로
private fun openUrl(url: String): Boolean = try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
diff --git a/android/app/src/main/res/xml/jj_file_paths.xml b/android/app/src/main/res/xml/jj_file_paths.xml
new file mode 100644
index 0000000..2088533
--- /dev/null
+++ b/android/app/src/main/res/xml/jj_file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/lib/core/app_update.dart b/lib/core/app_update.dart
index 16db126..07b3bac 100644
--- a/lib/core/app_update.dart
+++ b/lib/core/app_update.dart
@@ -61,15 +61,20 @@ class ReleaseInfo {
bool isNewerThan(String current) => compareVersions(version, current) > 0;
}
-/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null)
-ReleaseInfo? parseLatestRelease(Map j) {
+/// 플랫폼별 설치 파일 이름 (Windows zip / Android APK)
+const windowsAssetPattern = r'win64\.zip$';
+const androidAssetPattern = r'android_arm64\.apk$';
+
+/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null).
+/// [assetPattern]: 받을 설치 파일 (기본 Windows zip, Android 는 [androidAssetPattern]). zipUrl · zipName 등에 담는다.
+ReleaseInfo? parseLatestRelease(Map j, {String assetPattern = windowsAssetPattern}) {
if (j['draft'] == true || j['prerelease'] == true) return null;
final tag = j['tag_name'] as String?;
if (tag == null) return null;
Map? zip;
for (final a in (j['assets'] as List? ?? const [])) {
final m = a as Map;
- if (RegExp(r'win64\.zip$', caseSensitive: false).hasMatch(m['name'] as String? ?? '')) {
+ if (RegExp(assetPattern, caseSensitive: false).hasMatch(m['name'] as String? ?? '')) {
zip = m;
break;
}
@@ -78,8 +83,12 @@ ReleaseInfo? parseLatestRelease(Map j) {
if (sha != null && sha.startsWith('sha256:')) {
sha = sha.substring(7);
} else {
- // digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수) 사용
- sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase();
+ // digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수): 그 파일 이름이 있는 줄, 없으면 첫 번째
+ final body = j['body'] as String? ?? '';
+ final hex = RegExp(r'\b([0-9a-fA-F]{64})\b');
+ final name = zip?['name'] as String?;
+ final line = name == null ? null : body.split('\n').where((l) => l.contains(name) && hex.hasMatch(l)).firstOrNull;
+ sha = hex.firstMatch(line ?? body)?.group(1)?.toLowerCase();
}
return ReleaseInfo(
version: formatVersion(tag),
diff --git a/lib/main.dart b/lib/main.dart
index df3c773..de8ba43 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -364,6 +364,9 @@ Future runAndroid(String dataDir) async {
navigatorKey.currentState?.push(MaterialPageRoute(
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));
}
+ // 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음)
+ final ctx = navigatorKey.currentContext;
+ if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller));
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다
if (!await AndroidAccess.hasAllFiles()) {
messengerKey.currentState?.showSnackBar(SnackBar(
diff --git a/lib/platform/android/android_updater.dart b/lib/platform/android/android_updater.dart
new file mode 100644
index 0000000..f769989
--- /dev/null
+++ b/lib/platform/android/android_updater.dart
@@ -0,0 +1,107 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:crypto/crypto.dart';
+import 'package:flutter/services.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:path/path.dart' as p;
+
+import '../../core/app_update.dart';
+import '../../services/storage_service.dart';
+import '../../services/updater.dart';
+
+/// GitHub Release 로 업데이트 (Android)
+///
+/// 1. releases/latest 조회 (APK 파일) → 2. APK 받기 · SHA256 확인
+/// 3. Android 설치 화면을 연다 (MainActivity.kt "installApk"). 설치는 사용자가 [설치] 를 눌러야 한다.
+/// 같은 서명 키로 만든 APK 라 설정 · 받은 파일 · AI 모델은 그대로 남는다.
+class AndroidUpdater implements Updater {
+ static const _ch = MethodChannel('jj_mkvmaker/android');
+
+ final String apiBase;
+ final StorageService storage;
+ final HttpClient _http = HttpClient()
+ ..userAgent = 'JJ_MKVMaker-updater'
+ ..connectionTimeout = const Duration(seconds: 20);
+
+ AndroidUpdater(this.storage, {this.apiBase = 'https://api.github.com'});
+
+ /// 앱을 끝내지 않고 Android 설치 화면이 이어서 설치한다
+ @override
+ bool get installsInPlace => true;
+
+ /// 시험용: --dart-define=JJ_VERSION_OVERRIDE=2026.10.02_001 로 빌드하면 그 버전인 척한다 (업데이트 과정 점검)
+ static const _override = String.fromEnvironment('JJ_VERSION_OVERRIDE');
+
+ @override
+ Future currentVersion() async {
+ if (_override.isNotEmpty) return _override;
+ final info = await PackageInfo.fromPlatform();
+ return formatVersion('${info.version}+${info.buildNumber}');
+ }
+
+ @override
+ Future latest() async {
+ final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases/latest'));
+ req.headers.set('Accept', 'application/vnd.github+json');
+ final res = await req.close();
+ final body = await res.transform(utf8.decoder).join();
+ if (res.statusCode == 404) return null;
+ if (res.statusCode != 200) {
+ throw UpdateException('최신 버전을 확인할 수 없습니다 (${res.statusCode})');
+ }
+ return parseLatestRelease(jsonDecode(body) as Map, assetPattern: androidAssetPattern);
+ }
+
+ @override
+ Future canInstall() async => true;
+
+ @override
+ Future download(ReleaseInfo r, void Function(double progress) onProgress) async {
+ final url = r.zipUrl;
+ if (url == null) throw const UpdateException('이 버전에는 Android 용 APK 가 없습니다.');
+ final apk = File(p.join(await storage.tempDirectory(), r.zipName ?? 'update.apk'));
+ final req = await _http.getUrl(Uri.parse(url));
+ final res = await req.close();
+ if (res.statusCode != 200) throw UpdateException('내려받기 실패 (${res.statusCode})');
+ final total = res.contentLength > 0 ? res.contentLength : r.zipSize;
+ final sink = apk.openWrite();
+ var got = 0;
+ try {
+ await for (final chunk in res) {
+ sink.add(chunk);
+ got += chunk.length;
+ if (total > 0) onProgress((got / total).clamp(0.0, 0.99));
+ }
+ } finally {
+ await sink.close();
+ }
+ // 검증: GitHub 가 알려 준 SHA256 과 비교
+ if (r.sha256 != null) {
+ final hash = (await sha256.bind(apk.openRead()).first).toString();
+ if (hash != r.sha256) {
+ await apk.delete();
+ throw const UpdateException('받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.');
+ }
+ }
+ onProgress(1);
+ return apk.path;
+ }
+
+ /// Android 설치 화면 열기 ([downloaded] 는 APK 파일 경로)
+ @override
+ Future scheduleInstall(String downloaded) async {
+ try {
+ await _ch.invokeMethod('installApk', {'path': downloaded});
+ } on PlatformException catch (e) {
+ throw UpdateException(e.message ?? '$e');
+ }
+ }
+
+ @override
+ Future openPage(ReleaseInfo r) async {
+ try {
+ await _ch.invokeMethod('openUrl', {'url': r.pageUrl});
+ } catch (_) {}
+ }
+}
diff --git a/lib/platform/windows/github_updater.dart b/lib/platform/windows/github_updater.dart
index 43aa3c1..d1147e8 100644
--- a/lib/platform/windows/github_updater.dart
+++ b/lib/platform/windows/github_updater.dart
@@ -28,6 +28,9 @@ class GitHubUpdater implements Updater {
this.versionOverride,
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
+ @override
+ bool get installsInPlace => false;
+
@override
Future currentVersion() async {
if (versionOverride != null) return versionOverride!;
diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart
index 1992a5c..1bb39d9 100644
--- a/lib/services/platform_services.dart
+++ b/lib/services/platform_services.dart
@@ -12,6 +12,7 @@ import '../platform/common/whisper_recognizer.dart';
import '../platform/android/android_download_tools.dart';
import '../platform/android/android_shell.dart';
import '../platform/android/android_storage.dart';
+import '../platform/android/android_updater.dart';
import '../platform/android/ffmpeg_kit_media_tool.dart';
import '../platform/windows/aria2_backend.dart';
import '../platform/windows/desktop_shell.dart';
@@ -120,11 +121,13 @@ class PlatformServices {
);
}
if (Platform.isAndroid) {
- // 업데이트 · 탐색기 연결은 데스크톱 전용. 다운로드는 앱에 넣은 yt-dlp · aria2c (youtubedl-android)
+ // 탐색기 연결은 데스크톱 전용. 업데이트는 APK 를 받아 Android 설치 화면으로. 다운로드는 앱에 넣은 yt-dlp · aria2c (youtubedl-android)
MediaKitPreviewPlayer.ensureInitialized();
+ final storage = AndroidStorageService();
return PlatformServices(
mediaTool: FfmpegKitMediaTool(),
- storage: AndroidStorageService(),
+ storage: storage,
+ updater: AndroidUpdater(storage),
createPlayer: MediaKitPreviewPlayer.new,
createMediaPlayer: MediaKitFullPlayer.new,
createRecognizer: WhisperRecognizer.new,
diff --git a/lib/services/updater.dart b/lib/services/updater.dart
index e93321a..f5c9f67 100644
--- a/lib/services/updater.dart
+++ b/lib/services/updater.dart
@@ -28,4 +28,7 @@ abstract class Updater {
/// 브라우저로 Release 페이지 열기
Future openPage(ReleaseInfo r);
+
+ /// true 면 [scheduleInstall] 이 설치 화면을 열 뿐 앱을 끝낼 필요가 없다 (Android)
+ bool get installsInPlace => false;
}
diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart
index ee7b339..4458fdf 100644
--- a/lib/ui/settings_page.dart
+++ b/lib/ui/settings_page.dart
@@ -262,6 +262,28 @@ class _SettingsPageState extends State {
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
+ // 맨 위: 지금 버전 · 최신 버전 확인 (Windows · Android)
+ if (c.services.updater != null) ...[
+ _section('프로그램 정보 · 업데이트'),
+ ListTile(
+ leading: const Icon(Icons.system_update_alt, color: JjColors.accent),
+ title: FutureBuilder(
+ future: c.services.updater!.currentVersion(),
+ builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'),
+ ),
+ subtitle: const Text('GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다'),
+ trailing: FilledButton.icon(
+ onPressed: () => checkForUpdate(context, c, manual: true),
+ icon: const Icon(Icons.refresh, size: 18),
+ label: const Text('최신 버전 확인'),
+ ),
+ ),
+ SwitchListTile(
+ value: s.autoCheckUpdates,
+ onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
+ title: const Text('시작할 때 새 버전 확인 (하루 한 번)'),
+ ),
+ ],
_section('화면'),
ListTile(
title: const Text('기본 화면 크기'),
@@ -580,24 +602,6 @@ class _SettingsPageState extends State {
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
),
_section('프로그램'),
- if (c.services.updater != null) ...[
- ListTile(
- title: const Text('업데이트'),
- subtitle: FutureBuilder(
- future: c.services.updater!.currentVersion(),
- builder: (_, v) => Text('지금 버전: v${v.data ?? '…'} · GitHub 최신 Release 와 비교합니다'),
- ),
- trailing: OutlinedButton(
- onPressed: () => checkForUpdate(context, c, manual: true),
- child: const Text('지금 확인'),
- ),
- ),
- SwitchListTile(
- value: s.autoCheckUpdates,
- onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
- title: const Text('시작할 때 새 버전 확인 (하루 한 번)'),
- ),
- ],
if (desk) ...[
ListTile(
title: const Text('필수 프로그램 점검'),
diff --git a/lib/ui/update_dialog.dart b/lib/ui/update_dialog.dart
index cc576f7..3e15946 100644
--- a/lib/ui/update_dialog.dart
+++ b/lib/ui/update_dialog.dart
@@ -108,7 +108,7 @@ Future checkForUpdate(BuildContext context, AppController c, {bool manual
content: ValueListenableBuilder(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
- Text(v >= 1 ? '확인 · 압축 푸는 중…' : '${(v * 100).round()}%'),
+ Text(v >= 1 ? (up.installsInPlace ? '파일 확인 중…' : '확인 · 압축 푸는 중…') : '${(v * 100).round()}%'),
const SizedBox(height: 12),
LinearProgressIndicator(value: v >= 1 ? null : v),
]),
@@ -131,7 +131,8 @@ Future checkForUpdate(BuildContext context, AppController c, {bool manual
builder: (ctx) => AlertDialog(
title: const Text('설치 준비 완료'),
content: Text('v${r!.version} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n'
- '프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
+ '${up.installsInPlace ? 'Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n'
+ '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)' : '프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'}'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('나중에')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('지금 설치')),
@@ -139,6 +140,15 @@ Future checkForUpdate(BuildContext context, AppController c, {bool manual
),
);
if (go != true) return;
+ // Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움)
+ if (up.installsInPlace) {
+ try {
+ await up.scheduleInstall(dir);
+ } catch (e) {
+ snack('설치 화면을 열 수 없습니다: $e');
+ }
+ return;
+ }
// 다운로드 중이면 기존 종료 확인 절차
final ok = await (c.confirmQuit?.call() ?? Future.value(true));
if (!ok) return;
diff --git a/test/update_test.dart b/test/update_test.dart
index 9767d06..8202bc5 100644
--- a/test/update_test.dart
+++ b/test/update_test.dart
@@ -62,6 +62,24 @@ void main() {
expect(b.sha256, 'b' * 64);
expect(parseLatestRelease(_release('v9.0.0', draft: true)), isNull);
});
+
+ test('Android: APK 파일 · 그 파일 줄의 SHA256', () {
+ final j = _release('v2026.10.03_001',
+ body: '| `JJ_MKVMaker_v2026.10.03_001_win64.zip` | `${'C' * 64}` |\n'
+ '| `JJ_MKVMaker_v2026.10.03_001_android_arm64.apk` | `${'D' * 64}` |');
+ (j['assets'] as List).add({
+ 'name': 'JJ_MKVMaker_v2026.10.03_001_android_arm64.apk',
+ 'size': 92091507,
+ 'browser_download_url': 'https://example.com/x.apk',
+ });
+ final a = parseLatestRelease(j, assetPattern: androidAssetPattern)!;
+ expect([a.zipName, a.zipUrl, a.zipSize, a.sha256],
+ ['JJ_MKVMaker_v2026.10.03_001_android_arm64.apk', 'https://example.com/x.apk', 92091507, 'd' * 64]);
+ // Windows 는 그대로 zip
+ expect(parseLatestRelease(j)!.sha256, 'c' * 64);
+ // APK 가 없는 판
+ expect(parseLatestRelease(_release('v1.0.0'), assetPattern: androidAssetPattern)!.zipUrl, isNull);
+ });
test('하루 한 번', () {
final now = DateTime(2026, 10, 1, 12);
expect(updateCheckDue('', now), isTrue);
diff --git a/test/update_ui_test.dart b/test/update_ui_test.dart
index a53a38e..6fcf2d6 100644
--- a/test/update_ui_test.dart
+++ b/test/update_ui_test.dart
@@ -26,6 +26,8 @@ Map _release(String tag, {String? digest, String body = '', boo
};
class _FakeUpdater implements Updater {
+ @override
+ bool get installsInPlace => false;
ReleaseInfo? next;
final calls = [];
@override