199 lines
6.4 KiB
Dart
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();
|
|
}
|