설정 맨 위: 프로그램 정보 · 최신 버전 확인 (Windows · Android), Android 업데이트

- 환경 설정 맨 위에 지금 버전 · [최신 버전 확인] · 시작할 때 확인 (아래쪽 예전 업데이트 칸은 옮김)
- Android: GitHub 최신 Release 의 APK 를 받아 SHA256 확인 후 Android 설치 화면으로 (AndroidUpdater, MainActivity.installApk,
  FileProvider, REQUEST_INSTALL_PACKAGES). 앱을 끝내지 않고 설치 화면이 이어서 설치. 시작할 때 하루 한 번 자동 확인
- Release 해석: 플랫폼별 설치 파일 (win64.zip / android_arm64.apk), 설명의 SHA256 은 그 파일 이름이 있는 줄에서
- 시험용 --dart-define=JJ_VERSION_OVERRIDE (지금 버전을 낮춰 업데이트 과정 점검)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-03 03:31:30 +09:00
parent 97f1dd6312
commit 18d003f392
14 changed files with 244 additions and 27 deletions

View File

@ -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")
}

View File

@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="JJ_MKVMaker"
android:name="${applicationName}"
@ -37,6 +39,16 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- 업데이트 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>
<service
android:name=".KeepAliveService"
android:exported="false"

View File

@ -28,6 +28,12 @@ class MainActivity : FlutterActivity() {
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"installApk" -> try {
installApk(call.argument<String>("path") ?: "")
result.success(null)
} catch (e: Exception) {
result.error("INSTALL", e.message ?: e.toString(), null)
}
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("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))

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 업데이트 APK (MainActivity.installApk) -->
<paths>
<cache-path name="updates" path="updates/"/>
</paths>

View File

@ -61,15 +61,20 @@ class ReleaseInfo {
bool isNewerThan(String current) => compareVersions(version, current) > 0;
}
/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null)
ReleaseInfo? parseLatestRelease(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>? zip;
for (final a in (j['assets'] as List? ?? const [])) {
final m = a as Map<String, dynamic>;
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<String, dynamic> 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),

View File

@ -364,6 +364,9 @@ Future<void> runAndroid(String dataDir) async {
navigatorKey.currentState?.push(MaterialPageRoute<void>(
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(

View File

@ -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<String> currentVersion() async {
if (_override.isNotEmpty) return _override;
final info = await PackageInfo.fromPlatform();
return formatVersion('${info.version}+${info.buildNumber}');
}
@override
Future<ReleaseInfo?> 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<String, dynamic>, assetPattern: androidAssetPattern);
}
@override
Future<bool> canInstall() async => true;
@override
Future<String> 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<void> scheduleInstall(String downloaded) async {
try {
await _ch.invokeMethod<void>('installApk', {'path': downloaded});
} on PlatformException catch (e) {
throw UpdateException(e.message ?? '$e');
}
}
@override
Future<void> openPage(ReleaseInfo r) async {
try {
await _ch.invokeMethod<bool>('openUrl', {'url': r.pageUrl});
} catch (_) {}
}
}

View File

@ -28,6 +28,9 @@ class GitHubUpdater implements Updater {
this.versionOverride,
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
@override
bool get installsInPlace => false;
@override
Future<String> currentVersion() async {
if (versionOverride != null) return versionOverride!;

View File

@ -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,

View File

@ -28,4 +28,7 @@ abstract class Updater {
/// 브라우저로 Release 페이지 열기
Future<void> openPage(ReleaseInfo r);
/// true 면 [scheduleInstall] 이 설치 화면을 열 뿐 앱을 끝낼 필요가 없다 (Android)
bool get installsInPlace => false;
}

View File

@ -262,6 +262,28 @@ class _SettingsPageState extends State<SettingsPage> {
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<String>(
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<SettingsPage> {
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
),
_section('프로그램'),
if (c.services.updater != null) ...[
ListTile(
title: const Text('업데이트'),
subtitle: FutureBuilder<String>(
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('필수 프로그램 점검'),

View File

@ -108,7 +108,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
content: ValueListenableBuilder<double>(
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<void> 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<void> 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;

View File

@ -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);

View File

@ -26,6 +26,8 @@ Map<String, dynamic> _release(String tag, {String? digest, String body = '', boo
};
class _FakeUpdater implements Updater {
@override
bool get installsInPlace => false;
ReleaseInfo? next;
final calls = <String>[];
@override