diff --git a/i18n/en.json b/i18n/en.json index 9911ffdb4..30dfc1d96 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/ru.json b/i18n/ru.json index 3db4b2d5f..b5b2e189d 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -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": "Предоставить доступ пользователю", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a93c8af33..938a49e39 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + try { + when (call.method) { + "install" -> { + val path = requireNotNull(call.argument("path")) + val expectedVersion = requireNotNull(call.argument("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 { + 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() + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index fc9ab28fa..742bf007a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -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) } diff --git a/mobile/android/app/src/main/res/xml/update_paths.xml b/mobile/android/app/src/main/res/xml/update_paths.xml new file mode 100644 index 000000000..9e1f00dfd --- /dev/null +++ b/mobile/android/app/src/main/res/xml/update_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 02b07bce8..f1483470e 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -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 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 settings = SettingSection.values + final List 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.values.first); + final selectedSection = useState(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)), diff --git a/mobile/lib/services/app_update.service.dart b/mobile/lib/services/app_update.service.dart new file mode 100644 index 000000000..94aa32c4e --- /dev/null +++ b/mobile/lib/services/app_update.service.dart @@ -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 || 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(); +} diff --git a/mobile/lib/widgets/settings/app_update_settings.dart b/mobile/lib/widgets/settings/app_update_settings.dart new file mode 100644 index 000000000..4bdc98a59 --- /dev/null +++ b/mobile/lib/widgets/settings/app_update_settings.dart @@ -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 createState() => _AppUpdateSettingsState(); +} + +class _AppUpdateSettingsState extends State { + 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 _loadInstalled() async { + try { + final installed = await _service.installedVersion(); + if (mounted) { + setState(() => _installed = installed); + } + } catch (_) { + if (mounted) { + setState(() { + _error = true; + _message = 'local_update_error'; + }); + } + } + } + + Future _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 _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 _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), + ), + ], + ], + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b8c2b8e2b..14821ace0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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' diff --git a/mobile/test/services/app_update_service_test.dart b/mobile/test/services/app_update_service_test.dart new file mode 100644 index 000000000..1bdbc51f3 --- /dev/null +++ b/mobile/test/services/app_update_service_test.dart @@ -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 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 { + '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)[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())); + 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 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 = []; + 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())); + 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())); + 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()), + ); + 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 = []; + 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(); + }); +} diff --git a/mobile/test/unit/presentation/app_update_settings_test.dart b/mobile/test/unit/presentation/app_update_settings_test.dart new file mode 100644 index 000000000..11bb19fe2 --- /dev/null +++ b/mobile/test/unit/presentation/app_update_settings_test.dart @@ -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); + }); +} diff --git a/web/src/lib/utils/app-update.spec.ts b/web/src/lib/utils/app-update.spec.ts new file mode 100644 index 000000000..ce901cec8 --- /dev/null +++ b/web/src/lib/utils/app-update.spec.ts @@ -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(); + } + }); +}); diff --git a/web/src/lib/utils/app-update.ts b/web/src/lib/utils/app-update.ts new file mode 100644 index 000000000..32ac26047 --- /dev/null +++ b/web/src/lib/utils/app-update.ts @@ -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); + } +}; diff --git a/web/src/routes/(user)/user-settings/AppUpdateSettings.svelte b/web/src/routes/(user)/user-settings/AppUpdateSettings.svelte new file mode 100644 index 000000000..eb718b43a --- /dev/null +++ b/web/src/routes/(user)/user-settings/AppUpdateSettings.svelte @@ -0,0 +1,40 @@ + + +
+

{$t('local_update_web_description')}

+

{$t('local_update_installed')}: {version}

+ +

{message ? $t(message) : ''}

+ {#if available} +

{latest}

+ + {/if} +
diff --git a/web/src/routes/(user)/user-settings/UserSettingsList.svelte b/web/src/routes/(user)/user-settings/UserSettingsList.svelte index b6595ed60..890bb51de 100644 --- a/web/src/routes/(user)/user-settings/UserSettingsList.svelte +++ b/web/src/routes/(user)/user-settings/UserSettingsList.svelte @@ -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 @@ + + + +