설정 맨 위: 프로그램 정보 · 최신 버전 확인 (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
parent
97f1dd6312
commit
18d003f392
|
|
@ -84,4 +84,6 @@ dependencies {
|
||||||
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
|
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:ffmpeg:0.18.1")
|
||||||
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
|
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
|
||||||
|
// 업데이트 APK 를 설치 화면에 넘기는 FileProvider
|
||||||
|
implementation("androidx.core:core:1.16.0")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<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_DATA_SYNC"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<application
|
<application
|
||||||
android:label="JJ_MKVMaker"
|
android:label="JJ_MKVMaker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -37,6 +39,16 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".KeepAliveService"
|
android:name=".KeepAliveService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ class MainActivity : FlutterActivity() {
|
||||||
}
|
}
|
||||||
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
|
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
|
||||||
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
|
"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") ?: ""))
|
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||||
"keepAlive" -> {
|
"keepAlive" -> {
|
||||||
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
|
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 {
|
private fun openUrl(url: String): Boolean = try {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 업데이트 APK (MainActivity.installApk) -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/"/>
|
||||||
|
</paths>
|
||||||
|
|
@ -61,15 +61,20 @@ class ReleaseInfo {
|
||||||
bool isNewerThan(String current) => compareVersions(version, current) > 0;
|
bool isNewerThan(String current) => compareVersions(version, current) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null)
|
/// 플랫폼별 설치 파일 이름 (Windows zip / Android APK)
|
||||||
ReleaseInfo? parseLatestRelease(Map<String, dynamic> j) {
|
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;
|
if (j['draft'] == true || j['prerelease'] == true) return null;
|
||||||
final tag = j['tag_name'] as String?;
|
final tag = j['tag_name'] as String?;
|
||||||
if (tag == null) return null;
|
if (tag == null) return null;
|
||||||
Map<String, dynamic>? zip;
|
Map<String, dynamic>? zip;
|
||||||
for (final a in (j['assets'] as List? ?? const [])) {
|
for (final a in (j['assets'] as List? ?? const [])) {
|
||||||
final m = a as Map<String, dynamic>;
|
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;
|
zip = m;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -78,8 +83,12 @@ ReleaseInfo? parseLatestRelease(Map<String, dynamic> j) {
|
||||||
if (sha != null && sha.startsWith('sha256:')) {
|
if (sha != null && sha.startsWith('sha256:')) {
|
||||||
sha = sha.substring(7);
|
sha = sha.substring(7);
|
||||||
} else {
|
} else {
|
||||||
// digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수) 사용
|
// digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수): 그 파일 이름이 있는 줄, 없으면 첫 번째
|
||||||
sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase();
|
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(
|
return ReleaseInfo(
|
||||||
version: formatVersion(tag),
|
version: formatVersion(tag),
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,9 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
navigatorKey.currentState?.push(MaterialPageRoute<void>(
|
navigatorKey.currentState?.push(MaterialPageRoute<void>(
|
||||||
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));
|
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));
|
||||||
}
|
}
|
||||||
|
// 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음)
|
||||||
|
final ctx = navigatorKey.currentContext;
|
||||||
|
if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller));
|
||||||
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다
|
// 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다
|
||||||
if (!await AndroidAccess.hasAllFiles()) {
|
if (!await AndroidAccess.hasAllFiles()) {
|
||||||
messengerKey.currentState?.showSnackBar(SnackBar(
|
messengerKey.currentState?.showSnackBar(SnackBar(
|
||||||
|
|
|
||||||
|
|
@ -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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,9 @@ class GitHubUpdater implements Updater {
|
||||||
this.versionOverride,
|
this.versionOverride,
|
||||||
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get installsInPlace => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> currentVersion() async {
|
Future<String> currentVersion() async {
|
||||||
if (versionOverride != null) return versionOverride!;
|
if (versionOverride != null) return versionOverride!;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../platform/common/whisper_recognizer.dart';
|
||||||
import '../platform/android/android_download_tools.dart';
|
import '../platform/android/android_download_tools.dart';
|
||||||
import '../platform/android/android_shell.dart';
|
import '../platform/android/android_shell.dart';
|
||||||
import '../platform/android/android_storage.dart';
|
import '../platform/android/android_storage.dart';
|
||||||
|
import '../platform/android/android_updater.dart';
|
||||||
import '../platform/android/ffmpeg_kit_media_tool.dart';
|
import '../platform/android/ffmpeg_kit_media_tool.dart';
|
||||||
import '../platform/windows/aria2_backend.dart';
|
import '../platform/windows/aria2_backend.dart';
|
||||||
import '../platform/windows/desktop_shell.dart';
|
import '../platform/windows/desktop_shell.dart';
|
||||||
|
|
@ -120,11 +121,13 @@ class PlatformServices {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// 업데이트 · 탐색기 연결은 데스크톱 전용. 다운로드는 앱에 넣은 yt-dlp · aria2c (youtubedl-android)
|
// 탐색기 연결은 데스크톱 전용. 업데이트는 APK 를 받아 Android 설치 화면으로. 다운로드는 앱에 넣은 yt-dlp · aria2c (youtubedl-android)
|
||||||
MediaKitPreviewPlayer.ensureInitialized();
|
MediaKitPreviewPlayer.ensureInitialized();
|
||||||
|
final storage = AndroidStorageService();
|
||||||
return PlatformServices(
|
return PlatformServices(
|
||||||
mediaTool: FfmpegKitMediaTool(),
|
mediaTool: FfmpegKitMediaTool(),
|
||||||
storage: AndroidStorageService(),
|
storage: storage,
|
||||||
|
updater: AndroidUpdater(storage),
|
||||||
createPlayer: MediaKitPreviewPlayer.new,
|
createPlayer: MediaKitPreviewPlayer.new,
|
||||||
createMediaPlayer: MediaKitFullPlayer.new,
|
createMediaPlayer: MediaKitFullPlayer.new,
|
||||||
createRecognizer: WhisperRecognizer.new,
|
createRecognizer: WhisperRecognizer.new,
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,7 @@ abstract class Updater {
|
||||||
|
|
||||||
/// 브라우저로 Release 페이지 열기
|
/// 브라우저로 Release 페이지 열기
|
||||||
Future<void> openPage(ReleaseInfo r);
|
Future<void> openPage(ReleaseInfo r);
|
||||||
|
|
||||||
|
/// true 면 [scheduleInstall] 이 설치 화면을 열 뿐 앱을 끝낼 필요가 없다 (Android)
|
||||||
|
bool get installsInPlace => false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,28 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
children: [
|
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('화면'),
|
_section('화면'),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('기본 화면 크기'),
|
title: const Text('기본 화면 크기'),
|
||||||
|
|
@ -580,24 +602,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
|
'언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'),
|
||||||
),
|
),
|
||||||
_section('프로그램'),
|
_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) ...[
|
if (desk) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('필수 프로그램 점검'),
|
title: const Text('필수 프로그램 점검'),
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
||||||
content: ValueListenableBuilder<double>(
|
content: ValueListenableBuilder<double>(
|
||||||
valueListenable: progress,
|
valueListenable: progress,
|
||||||
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
LinearProgressIndicator(value: v >= 1 ? null : v),
|
LinearProgressIndicator(value: v >= 1 ? null : v),
|
||||||
]),
|
]),
|
||||||
|
|
@ -131,7 +131,8 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('설치 준비 완료'),
|
title: const Text('설치 준비 완료'),
|
||||||
content: Text('v${r!.version} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n'
|
content: Text('v${r!.version} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n'
|
||||||
'프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
|
'${up.installsInPlace ? 'Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n'
|
||||||
|
'(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)' : '프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'}'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('나중에')),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('나중에')),
|
||||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), 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;
|
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));
|
final ok = await (c.confirmQuit?.call() ?? Future.value(true));
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,24 @@ void main() {
|
||||||
expect(b.sha256, 'b' * 64);
|
expect(b.sha256, 'b' * 64);
|
||||||
expect(parseLatestRelease(_release('v9.0.0', draft: true)), isNull);
|
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('하루 한 번', () {
|
test('하루 한 번', () {
|
||||||
final now = DateTime(2026, 10, 1, 12);
|
final now = DateTime(2026, 10, 1, 12);
|
||||||
expect(updateCheckDue('', now), isTrue);
|
expect(updateCheckDue('', now), isTrue);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ Map<String, dynamic> _release(String tag, {String? digest, String body = '', boo
|
||||||
};
|
};
|
||||||
|
|
||||||
class _FakeUpdater implements Updater {
|
class _FakeUpdater implements Updater {
|
||||||
|
@override
|
||||||
|
bool get installsInPlace => false;
|
||||||
ReleaseInfo? next;
|
ReleaseInfo? next;
|
||||||
final calls = <String>[];
|
final calls = <String>[];
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue