feat: add verified Android and web update checks in settings

This commit is contained in:
Codex
2026-10-15 14:54:44 +03:00
parent 9c76f93015
commit 6665bb4796
16 changed files with 910 additions and 5 deletions
+17
View File
@@ -1327,6 +1327,23 @@
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"local_update_allow": "Allow installation",
"local_update_available": "Update available",
"local_update_cancelled": "Download cancelled.",
"local_update_check": "Check for updates",
"local_update_checking": "Checking…",
"local_update_current": "You are up to date.",
"local_update_description": "Check and install Android updates from Argus.",
"local_update_download": "Download and install",
"local_update_download_error": "Could not download, verify or open the update. Please try again.",
"local_update_error": "Could not check for updates. Check your connection and try again.",
"local_update_install": "Install update",
"local_update_installed": "Installed version",
"local_update_installer_opened": "Complete the update in the Android installer.",
"local_update_permission": "Allow Immich to install apps, return here and tap Install update.",
"local_update_web_description": "Check for a new web app version on your server.",
"local_update_web_install": "Load new version",
"local_updates": "Updates",
"location": "Location",
"location_permission": "Location permission",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current Wi-Fi network's name",
+18 -1
View File
@@ -1326,6 +1326,23 @@
"local_media_summary": "Информация об объекте на устройстве",
"local_network": "Локальная сеть",
"local_network_sheet_info": "Приложение будет подключаться к серверу по этому адресу, когда устройство подключено к указанной Wi-Fi сети",
"local_update_allow": "Разрешить установку",
"local_update_available": "Доступно обновление",
"local_update_cancelled": "Скачивание отменено.",
"local_update_check": "Проверить обновления",
"local_update_checking": "Проверка…",
"local_update_current": "Установлена актуальная версия.",
"local_update_description": "Проверка и установка обновлений Android через Argus.",
"local_update_download": "Скачать и установить",
"local_update_download_error": "Не удалось скачать, проверить или открыть обновление. Повторите попытку.",
"local_update_error": "Не удалось проверить обновления. Проверьте подключение и повторите попытку.",
"local_update_install": "Установить обновление",
"local_update_installed": "Установленная версия",
"local_update_installer_opened": "Завершите обновление в открывшемся установщике Android.",
"local_update_permission": "Разрешите Immich устанавливать приложения, вернитесь сюда и нажмите «Установить обновление».",
"local_update_web_description": "Проверка новой версии веб-приложения на вашем сервере.",
"local_update_web_install": "Загрузить новую версию",
"local_updates": "Обновления",
"location": "Место",
"location_permission": "Доступ к местоположению",
"location_permission_content": "Чтобы использовать функцию автоматического переключения, Immich необходимо разрешение на точное определение местоположения, чтобы оно могло считывать название текущей Wi-Fi сети",
@@ -2011,7 +2028,7 @@
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
"share_link": "Создать ссылку",
"share_original": "Оригинал (большой размер)",
"share_preview": "Миниатюра (маленький размер)",
"share_preview": "Предпросмотр (небольшой размер)",
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
"share_quality_title": "Выберите качество для функции \"Поделиться\"",
"share_with": "Предоставить доступ пользователю",
@@ -2,6 +2,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -31,6 +32,11 @@
android:networkSecurityConfig="@xml/network_security_config">
<profileable android:shell="true" />
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updates" android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/update_paths" />
</provider>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -0,0 +1,74 @@
package app.alextran.immich
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.security.MessageDigest
/** Only verified updates to this locally signed application can reach the system installer. */
class AppUpdateInstaller(private val activity: Activity, messenger: BinaryMessenger) {
init {
MethodChannel(messenger, "app.alextran.immich/updates").setMethodCallHandler { call, result ->
try {
when (call.method) {
"install" -> {
val path = requireNotNull(call.argument<String>("path"))
val expectedVersion = requireNotNull(call.argument<Number>("versionCode")).toLong()
val file = File(path).canonicalFile
val directory = File(activity.cacheDir, "immich-updates").canonicalFile
require(file.parentFile == directory && file.name == "update.apk" && file.isFile)
val manager = activity.packageManager
@Suppress("DEPRECATION")
val flags = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES
@Suppress("DEPRECATION")
val archive = requireNotNull(manager.getPackageArchiveInfo(file.path, flags))
@Suppress("DEPRECATION")
val installed = manager.getPackageInfo(activity.packageName, flags)
require(archive.packageName == activity.packageName && archive.packageName == "app.alextran.immich")
require(version(archive) == expectedVersion && expectedVersion > version(installed))
val allowed = setOf("4affa77b971214e936a57da74e161e19c9a01d24cce74bb6690bbde828e9be47")
require(signers(archive) == allowed && signers(installed) == allowed)
if (!manager.canRequestPackageInstalls()) {
result.success("permissionRequired")
} else {
val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.updates", file)
activity.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
})
result.success("started")
}
}
"openInstallPermission" -> {
activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}")))
result.success(null)
}
else -> result.notImplemented()
}
} catch (_: Exception) {
result.error("UPDATE_INSTALL_FAILED", "Unable to verify or install this update", null)
}
}
}
@Suppress("DEPRECATION")
private fun version(info: PackageInfo): Long =
if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
@Suppress("DEPRECATION")
private fun signers(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures
return signatures.orEmpty().map { signature ->
MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") { "%02x".format(it) }
}.toSet()
}
}
@@ -30,6 +30,7 @@ import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger)
registerPlugins(this, flutterEngine)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="immich-updates/" />
</paths>
+12 -3
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -5,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/app_update_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
@@ -17,6 +20,7 @@ import 'package:immich_mobile/widgets/settings/preference_settings/preference_se
import 'package:immich_ui/immich_ui.dart';
enum SettingSection {
updates(Icons.system_update),
advanced(Icons.build_outlined),
assetViewer(Icons.image_outlined),
backup(Icons.cloud_upload_outlined),
@@ -31,6 +35,7 @@ enum SettingSection {
final IconData icon;
String title(Translations t) => switch (this) {
SettingSection.updates => t.local_updates,
SettingSection.advanced => t.advanced,
SettingSection.assetViewer => t.asset_viewer_settings_title,
SettingSection.backup => t.backup,
@@ -44,6 +49,7 @@ enum SettingSection {
};
String subtitle(Translations t) => switch (this) {
SettingSection.updates => t.local_update_description,
SettingSection.advanced => t.advanced_settings_tile_subtitle,
SettingSection.assetViewer => t.asset_viewer_settings_subtitle,
SettingSection.backup => t.backup_settings_subtitle,
@@ -57,6 +63,7 @@ enum SettingSection {
};
Widget get widget => switch (this) {
SettingSection.updates => const AppUpdateSettings(),
SettingSection.advanced => const AdvancedSettings(),
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup => const BackupSettings(),
@@ -70,6 +77,8 @@ enum SettingSection {
};
const SettingSection(this.icon);
static List<SettingSection> get visible => values.where((s) => s != updates || Platform.isAndroid).toList();
}
@RoutePage()
@@ -89,7 +98,7 @@ class _MobileLayout extends StatelessWidget {
const _MobileLayout();
@override
Widget build(BuildContext context) {
final List<Widget> settings = SettingSection.values
final List<Widget> settings = SettingSection.visible
.expand(
(setting) => setting == SettingSection.beta
? [
@@ -126,7 +135,7 @@ class _TabletLayout extends HookWidget {
const _TabletLayout();
@override
Widget build(BuildContext context) {
final selectedSection = useState<SettingSection>(SettingSection.values.first);
final selectedSection = useState<SettingSection>(SettingSection.visible.first);
return Row(
mainAxisAlignment: MainAxisAlignment.start,
@@ -135,7 +144,7 @@ class _TabletLayout extends HookWidget {
flex: 2,
child: CustomScrollView(
slivers: [
...SettingSection.values.map(
...SettingSection.visible.map(
(s) => SliverToBoxAdapter(
child: ListTile(
title: Text(s.title(context.t)),
+198
View File
@@ -0,0 +1,198 @@
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();
}
@@ -0,0 +1,209 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/services/app_update.service.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AppUpdateSettings extends StatefulWidget {
final AppUpdateService? service;
const AppUpdateSettings({super.key, this.service});
@override
State<AppUpdateSettings> createState() => _AppUpdateSettingsState();
}
class _AppUpdateSettingsState extends State<AppUpdateSettings> {
late final _service = widget.service ?? AppUpdateService();
PackageInfo? _installed;
AppRelease? _release;
File? _apk;
bool _busy = false;
bool _downloading = false;
bool _cancelled = false;
bool _permissionRequired = false;
double _progress = 0;
String? _message;
bool _error = false;
@override
void initState() {
super.initState();
unawaited(_loadInstalled());
}
Future<void> _loadInstalled() async {
try {
final installed = await _service.installedVersion();
if (mounted) {
setState(() => _installed = installed);
}
} catch (_) {
if (mounted) {
setState(() {
_error = true;
_message = 'local_update_error';
});
}
}
}
Future<void> _check() async {
setState(() {
_busy = true;
_error = false;
_message = null;
_release = null;
_apk = null;
_permissionRequired = false;
});
try {
final installed = await _service.installedVersion();
final release = await _service.check();
if (!mounted) {
return;
}
setState(() {
_installed = installed;
_release = release != null && release.isNewerThan(installed.buildNumber) ? release : null;
_message = _release == null ? 'local_update_current' : 'local_update_available';
});
} catch (_) {
if (mounted) {
setState(() {
_error = true;
_message = 'local_update_error';
});
}
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
Future<void> _downloadAndInstall() async {
final release = _release;
if (release == null) {
return;
}
setState(() {
_busy = true;
_downloading = _apk == null;
_cancelled = false;
_progress = 0;
_error = false;
_message = null;
});
try {
_apk ??= await _service.download(release, (progress) {
if (mounted) {
setState(() => _progress = progress);
}
});
if (!mounted || _cancelled) {
return;
}
setState(() => _downloading = false);
final started = await _service.install(_apk!, release);
if (mounted) {
setState(() {
_permissionRequired = !started;
_message = started ? 'local_update_installer_opened' : 'local_update_permission';
});
}
} catch (_) {
if (mounted) {
setState(() {
_error = !_cancelled;
_message = _cancelled ? 'local_update_cancelled' : 'local_update_download_error';
});
}
} finally {
if (mounted) {
setState(() {
_busy = false;
_downloading = false;
});
}
}
}
Future<void> _openPermission() async {
try {
await _service.openInstallPermission();
} catch (_) {
if (mounted) {
setState(() {
_error = true;
_message = 'local_update_error';
});
}
}
}
@override
void dispose() {
_service.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.t;
final release = _release;
return ListView(
padding: const EdgeInsets.all(20),
children: [
Text(t.local_update_description),
const SizedBox(height: 16),
if (_installed != null)
Text('${t.local_update_installed}: ${_installed!.version} (${_installed!.buildNumber})'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _busy ? null : _check,
icon: const Icon(Icons.system_update),
label: Text(t.local_update_check),
),
if (_busy && !_downloading)
const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
if (_message != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
_message!.tr(context: context),
style: TextStyle(color: _error ? Theme.of(context).colorScheme.error : null),
),
),
if (release != null) ...[
Text('${t.local_update_available}: ${release.version}', style: Theme.of(context).textTheme.titleMedium),
Text('${(release.size / (1024 * 1024)).toStringAsFixed(1)} MB'),
if (release.notes.isNotEmpty)
Padding(padding: const EdgeInsets.symmetric(vertical: 16), child: Text(release.notes)),
if (_downloading) ...[
LinearProgressIndicator(value: _progress),
Text('${(_progress * 100).floor()}%'),
TextButton(
onPressed: () {
_cancelled = true;
_service.cancel();
},
child: Text(t.cancel),
),
],
if (_permissionRequired)
OutlinedButton(onPressed: _busy ? null : _openPermission, child: Text(t.local_update_allow)),
FilledButton(
onPressed: _busy ? null : _downloadAndInstall,
child: Text(_apk == null ? t.local_update_download : t.local_update_install),
),
],
],
);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.3.3-local.1+3030400
version: 3.3.3-local.2+3030401
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:immich_mobile/services/app_update.service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const id = 'be34e6a7-5970-41ab-af01-71789e844f73';
final bytes = utf8.encode('APK test fixture');
Map<String, dynamic> manifest() => {
'slug': 'immich-local',
'release': {
'id': id,
'version': '3.3.3-local.2',
'androidVersionCode': 3030401,
'channel': 'stable',
'platform': 'android',
'packageKind': 'apk',
'downloadPath': '/api/apps/immich-local/releases/$id/download',
'packageSizeBytes': bytes.length,
'sha256': sha256.convert(bytes).toString(),
'notes': 'Changes',
},
};
test('compares Android build numbers; same and older releases cannot be installed', () {
final release = AppRelease.fromManifest(manifest())!;
expect(release.isNewerThan('3030400'), isTrue);
expect(release.isNewerThan('3030401'), isFalse);
expect(release.isNewerThan('3030402'), isFalse);
expect(() => release.isNewerThan('unknown'), throwsFormatException);
expect(release.downloadUri.host, 'argus.kusoft.xyz');
});
test('a catalog with no release is valid', () {
expect(AppRelease.fromManifest({'slug': 'immich-local', 'release': null}), isNull);
});
test('rejects another application catalog', () {
expect(() => AppRelease.fromManifest({...manifest(), 'slug': 'another-app'}), throwsFormatException);
});
for (final entry in <String, Object?>{
'id': '../invalid',
'platform': 'ios',
'channel': 'beta',
'packageKind': 'zip',
'androidVersionCode': '3030401',
'version': '',
'packageSizeBytes': 0,
'sha256': 'invalid',
'downloadPath': 'https://example.com/app.apk',
'notes': 42,
}.entries) {
test('rejects invalid ${entry.key}', () {
final data = manifest();
(data['release'] as Map<String, dynamic>)[entry.key] = entry.value;
expect(() => AppRelease.fromManifest(data), throwsFormatException);
});
}
test('queries only stable Android releases without cache or redirects', () async {
final service = AppUpdateService(
clientFactory: () => MockClient((request) async {
expect(
request.url.toString(),
'https://argus.kusoft.xyz/api/apps/immich-local/manifest?platform=android&channel=stable',
);
expect(request.followRedirects, isFalse);
expect(request.headers['Cache-Control'], 'no-cache');
return http.Response(jsonEncode(manifest()), 200);
}),
);
expect((await service.check())!.versionCode, 3030401);
service.dispose();
});
test('server errors are reported instead of saying up to date', () async {
final service = AppUpdateService(clientFactory: () => MockClient((_) async => http.Response('', 503)));
await expectLater(service.check(), throwsA(isA<HttpException>()));
service.dispose();
});
group('verified download', () {
late Directory directory;
setUp(() async => directory = await Directory.systemTemp.createTemp('immich-update-test-'));
tearDown(() async => directory.delete(recursive: true));
AppUpdateService service(List<int> body, {int status = 200}) => AppUpdateService(
temporaryDirectory: () async => directory,
clientFactory: () => MockClient((request) async {
expect(request.followRedirects, isFalse);
return http.Response.bytes(body, status);
}),
);
test('writes an APK only after its size and digest match', () async {
final updater = service(bytes);
final progress = <double>[];
final apk = await updater.download(AppRelease.fromManifest(manifest())!, progress.add);
expect(await apk.readAsBytes(), bytes);
expect(progress.last, 1);
expect(File('${apk.path}.part').existsSync(), isFalse);
updater.dispose();
});
for (final body in [
utf8.encode('wrong bytes here'),
bytes.sublist(1),
[...bytes, 0],
]) {
test('rejects corrupt or truncated content and removes the partial file (${body.length})', () async {
final updater = service(body);
await expectLater(updater.download(AppRelease.fromManifest(manifest())!, (_) {}), throwsA(isA<Exception>()));
expect(File('${directory.path}/immich-updates/update.apk').existsSync(), isFalse);
expect(File('${directory.path}/immich-updates/update.apk.part').existsSync(), isFalse);
updater.dispose();
});
}
test('refuses redirects', () async {
final updater = service(bytes, status: 302);
await expectLater(updater.download(AppRelease.fromManifest(manifest())!, (_) {}), throwsA(isA<HttpException>()));
updater.dispose();
});
test('cancellation never leaves an installable APK', () async {
final updater = service(bytes);
await expectLater(
updater.download(AppRelease.fromManifest(manifest())!, (_) => updater.cancel()),
throwsA(isA<UpdateCancelled>()),
);
expect(File('${directory.path}/immich-updates/update.apk').existsSync(), isFalse);
updater.dispose();
});
});
test('uses the native installer and reports missing install permission', () async {
final calls = <MethodCall>[];
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(AppUpdateService.channel, (call) async {
calls.add(call);
return call.method == 'install' ? 'permissionRequired' : null;
});
addTearDown(() => messenger.setMockMethodCallHandler(AppUpdateService.channel, null));
final updater = AppUpdateService();
expect(
await updater.install(File('/cache/immich-updates/update.apk'), AppRelease.fromManifest(manifest())!),
isFalse,
);
await updater.openInstallPermission();
expect(calls.map((call) => call.method), ['install', 'openInstallPermission']);
expect(calls.first.arguments['versionCode'], 3030401);
updater.dispose();
});
}
@@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/services/app_update.service.dart';
import 'package:immich_mobile/widgets/settings/app_update_settings.dart';
import 'package:mocktail/mocktail.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'presentation_context.dart';
class _Updater extends Mock implements AppUpdateService {}
void main() {
late PresentationContext context;
late _Updater updater;
final release = AppRelease(
'3.3.3-local.2',
3030401,
'Release notes',
Uri.parse('https://argus.kusoft.xyz/test'),
100,
'a' * 64,
);
setUp(() async {
context = await PresentationContext.create();
updater = _Updater();
when(() => updater.installedVersion()).thenAnswer(
(_) async => PackageInfo(
appName: 'Immich',
packageName: 'app.alextran.immich',
version: '3.3.3-local.1',
buildNumber: '3030400',
),
);
});
tearDown(() async => context.dispose());
testWidgets('downloaded update can be installed after granting permission without downloading again', (tester) async {
final apk = File('/cache/immich-updates/update.apk');
registerFallbackValue((double progress) {});
when(() => updater.check()).thenAnswer((_) async => release);
when(() => updater.download(release, any())).thenAnswer((_) async => apk);
when(() => updater.install(apk, release)).thenAnswer((_) async => false);
when(() => updater.openInstallPermission()).thenAnswer((_) async {});
await tester.pumpTestWidget(context, AppUpdateSettings(service: updater));
await tester.tap(find.text('Check for updates'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Download and install'));
await tester.tap(find.text('Download and install'));
await tester.pumpAndSettle();
expect(find.textContaining('Allow Immich to install apps'), findsOneWidget);
await tester.ensureVisible(find.text('Allow installation'));
await tester.tap(find.text('Allow installation'));
await tester.pumpAndSettle();
verify(() => updater.openInstallPermission()).called(1);
when(() => updater.install(apk, release)).thenAnswer((_) async => true);
await tester.ensureVisible(find.text('Install update'));
await tester.tap(find.text('Install update'));
await tester.pumpAndSettle();
expect(find.text('Complete the update in the Android installer.'), findsOneWidget);
verify(() => updater.download(release, any())).called(1);
verify(() => updater.install(apk, release)).called(2);
});
testWidgets('checking is user initiated and a current version has no install action', (tester) async {
when(() => updater.check()).thenAnswer((_) async => null);
await tester.pumpTestWidget(context, AppUpdateSettings(service: updater));
verifyNever(() => updater.check());
await tester.tap(find.text('Check for updates'));
await tester.pumpAndSettle();
expect(find.text('You are up to date.'), findsOneWidget);
expect(find.text('Download and install'), findsNothing);
});
testWidgets('a check error offers retry without an install button', (tester) async {
when(() => updater.check()).thenThrow(const SocketException('offline'));
await tester.pumpTestWidget(context, AppUpdateSettings(service: updater));
await tester.tap(find.text('Check for updates'));
await tester.pumpAndSettle();
expect(find.textContaining('Could not check for updates'), findsOneWidget);
expect(find.text('Download and install'), findsNothing);
when(() => updater.check()).thenAnswer((_) async => release);
await tester.tap(find.text('Check for updates'));
await tester.pumpAndSettle();
expect(find.text('Release notes'), findsOneWidget);
expect(find.text('Download and install'), findsOneWidget);
});
}
+44
View File
@@ -0,0 +1,44 @@
import { checkWebUpdate } from './app-update';
describe('web update checking', () => {
it.each([
['same', false],
['new-build', true],
])('compares the deployed build %s', async (version, available) => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ version })));
expect(await checkWebUpdate('same', request)).toEqual({ version, available });
expect(request).toHaveBeenCalledWith(
'/_app/version.json',
expect.objectContaining({ cache: 'no-store', redirect: 'error' }),
);
});
it.each([null, {}, { version: '' }, { version: 42 }])('rejects an invalid version response %j', async (body) => {
await expect(
checkWebUpdate('same', vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))),
).rejects.toThrow();
});
it('reports HTTP errors instead of treating them as no updates', async () => {
await expect(
checkWebUpdate('same', vi.fn().mockResolvedValue(new Response('', { status: 503 }))),
).rejects.toThrow();
});
it('aborts a stalled request', async () => {
vi.useFakeTimers();
try {
const request = vi.fn().mockImplementation(
(_url, options: RequestInit) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const result = expect(checkWebUpdate('same', request)).rejects.toThrow('aborted');
await vi.advanceTimersByTimeAsync(15_000);
await result;
} finally {
vi.useRealTimers();
}
});
});
+28
View File
@@ -0,0 +1,28 @@
export const checkWebUpdate = async (currentVersion: string, request: typeof fetch = fetch) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await request('/_app/version.json', {
cache: 'no-store',
redirect: 'error',
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Version check failed');
}
const data: unknown = await response.json();
if (
!data ||
typeof data !== 'object' ||
!('version' in data) ||
typeof data.version !== 'string' ||
!data.version ||
data.version.length > 200
) {
throw new Error('Invalid version response');
}
return { version: data.version, available: data.version !== currentVersion };
} finally {
clearTimeout(timeout);
}
};
@@ -0,0 +1,40 @@
<script lang="ts">
import { version } from '$app/environment';
import { checkWebUpdate } from '$lib/utils/app-update';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
let checking = $state(false);
let available = $state(false);
let latest = $state('');
let message = $state<'' | 'local_update_available' | 'local_update_current' | 'local_update_error'>('');
const check = async () => {
checking = true;
available = false;
message = '';
try {
const result = await checkWebUpdate(version);
latest = result.version;
available = result.available;
message = available ? 'local_update_available' : 'local_update_current';
} catch {
message = 'local_update_error';
} finally {
checking = false;
}
};
</script>
<section class="my-4 flex flex-col items-start gap-4 sm:ms-8">
<p>{$t('local_update_web_description')}</p>
<p>{$t('local_update_installed')}: {version}</p>
<Button onclick={check} disabled={checking}>
{checking ? $t('local_update_checking') : $t('local_update_check')}
</Button>
<p role="status" aria-live="polite">{message ? $t(message) : ''}</p>
{#if available}
<p>{latest}</p>
<Button onclick={() => location.reload()}>{$t('local_update_web_install')}</Button>
{/if}
</section>
@@ -28,6 +28,7 @@
import { t } from 'svelte-i18n';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import AppSettings from './AppSettings.svelte';
import AppUpdateSettings from './AppUpdateSettings.svelte';
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
import DeviceList from './DeviceList.svelte';
import OauthSettings from './OauthSettings.svelte';
@@ -59,6 +60,15 @@
<UserProfileSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiDownload}
key="updates"
title={$t('local_updates')}
subtitle={$t('local_update_web_description')}
>
<AppUpdateSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiServerOutline}
key="user-usage-info"