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