Files
Immich/mobile/lib/services/app_update.service.dart
T

199 lines
6.4 KiB
Dart

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<String, dynamic> || value['slug'] != slug) {
throw const FormatException('Unexpected update catalog');
}
final release = value['release'];
if (release == null) {
return null;
}
if (release is! Map<String, dynamic>) {
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<Directory> Function() _temporaryDirectory;
http.Client? _activeClient;
bool _cancelled = false;
AppUpdateService({http.Client Function()? clientFactory, Future<Directory> Function()? temporaryDirectory})
: _clientFactory = clientFactory ?? http.Client.new,
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
Future<PackageInfo> installedVersion() => PackageInfo.fromPlatform();
Future<AppRelease?> 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 = <int>[];
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<File> 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<bool> install(File file, AppRelease release) async =>
await channel.invokeMethod<String>('install', {'path': file.path, 'versionCode': release.versionCode}) ==
'started';
Future<void> openInstallPermission() => channel.invokeMethod<void>('openInstallPermission');
void cancel() {
_cancelled = true;
_activeClient?.close();
}
void dispose() => cancel();
}
class UpdateCancelled implements Exception {
const UpdateCancelled();
}