설정 맨 위: 프로그램 정보 · 최신 버전 확인 (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:ffmpeg: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_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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// 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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
||||
|
||||
@override
|
||||
bool get installsInPlace => false;
|
||||
|
||||
@override
|
||||
Future<String> currentVersion() async {
|
||||
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_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,
|
||||
|
|
|
|||
|
|
@ -28,4 +28,7 @@ abstract class Updater {
|
|||
|
||||
/// 브라우저로 Release 페이지 열기
|
||||
Future<void> openPage(ReleaseInfo r);
|
||||
|
||||
/// true 면 [scheduleInstall] 이 설치 화면을 열 뿐 앱을 끝낼 필요가 없다 (Android)
|
||||
bool get installsInPlace => false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('필수 프로그램 점검'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue