import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; class AppRelease { static const slug = 'immich-local'; static final origin = Uri.parse('https://argus.kusoft.xyz'); final String version; final int versionCode; final String notes; final Uri downloadUri; final int size; final String sha256sum; const AppRelease(this.version, this.versionCode, this.notes, this.downloadUri, this.size, this.sha256sum); static AppRelease? fromManifest(Object? value) { if (value is! Map || value['slug'] != slug) { throw const FormatException('Unexpected update catalog'); } final release = value['release']; if (release == null) { return null; } if (release is! Map) { throw const FormatException('Invalid release'); } final id = release['id']; final version = release['version']; final code = release['androidVersionCode']; final size = release['packageSizeBytes']; final hash = release['sha256']; final notes = release['notes']; if (id is! String || !RegExp(r'^[a-fA-F0-9-]{36}$').hasMatch(id) || version is! String || version.isEmpty || version.length > 100 || code is! int || code <= 0 || size is! int || size <= 0 || size > 1024 * 1024 * 1024 || hash is! String || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(hash) || release['platform'] != 'android' || release['channel'] != 'stable' || release['packageKind'] != 'apk' || release['downloadPath'] != '/api/apps/$slug/releases/$id/download' || (notes != null && notes is! String)) { throw const FormatException('Invalid update metadata'); } return AppRelease( version, code, notes as String? ?? '', origin.resolve(release['downloadPath'] as String), size, hash.toLowerCase(), ); } bool isNewerThan(String installedCode) { final code = int.tryParse(installedCode); if (code == null || code <= 0) { throw const FormatException('Invalid installed version'); } return versionCode > code; } } class AppUpdateService { static const channel = MethodChannel('app.alextran.immich/updates'); final http.Client Function() _clientFactory; final Future Function() _temporaryDirectory; http.Client? _activeClient; bool _cancelled = false; AppUpdateService({http.Client Function()? clientFactory, Future Function()? temporaryDirectory}) : _clientFactory = clientFactory ?? http.Client.new, _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; Future installedVersion() => PackageInfo.fromPlatform(); Future check() async { final client = _clientFactory(); _activeClient = client; try { final request = http.Request( 'GET', AppRelease.origin.resolve('/api/apps/immich-local/manifest?platform=android&channel=stable'), ) ..followRedirects = false ..headers['Cache-Control'] = 'no-cache'; final response = await client.send(request).timeout(const Duration(seconds: 20)); if (response.statusCode != 200) { throw const HttpException('Update check failed'); } final bytes = []; await for (final chunk in response.stream.timeout(const Duration(seconds: 20))) { bytes.addAll(chunk); if (bytes.length > 1024 * 1024) { throw const FormatException('Update manifest too large'); } } return AppRelease.fromManifest(jsonDecode(utf8.decode(bytes))); } finally { client.close(); if (identical(client, _activeClient)) { _activeClient = null; } } } Future download(AppRelease release, void Function(double) progress) async { _cancelled = false; final directory = Directory('${(await _temporaryDirectory()).path}/immich-updates'); await directory.create(recursive: true); final file = File('${directory.path}/update.apk'); final partial = File('${directory.path}/update.apk.part'); if (file.existsSync()) { await file.delete(); } final client = _clientFactory(); _activeClient = client; IOSink? sink; try { if (_cancelled) { throw const UpdateCancelled(); } final request = http.Request('GET', release.downloadUri)..followRedirects = false; final response = await client.send(request).timeout(const Duration(seconds: 30)); if (response.statusCode != 200 || (response.contentLength != null && response.contentLength != release.size)) { throw const HttpException('APK download failed'); } sink = partial.openWrite(); var count = 0; await for (final chunk in response.stream.timeout(const Duration(seconds: 30))) { if (_cancelled) { throw const UpdateCancelled(); } count += chunk.length; if (count > release.size) { throw const FormatException('APK size mismatch'); } sink.add(chunk); await sink.flush(); progress(count / release.size); } await sink.flush(); await sink.close(); sink = null; if (_cancelled) { throw const UpdateCancelled(); } if (count != release.size || (await sha256.bind(partial.openRead()).first).toString() != release.sha256sum) { throw const FormatException('APK integrity check failed'); } if (_cancelled) { throw const UpdateCancelled(); } return await partial.rename(file.path); } finally { await sink?.close(); client.close(); if (identical(client, _activeClient)) { _activeClient = null; } if (partial.existsSync()) { await partial.delete(); } } } Future install(File file, AppRelease release) async => await channel.invokeMethod('install', {'path': file.path, 'versionCode': release.versionCode}) == 'started'; Future openInstallPermission() => channel.invokeMethod('openInstallPermission'); void cancel() { _cancelled = true; _activeClient?.close(); } void dispose() => cancel(); } class UpdateCancelled implements Exception { const UpdateCancelled(); }