feat: add verified Android and web update checks in settings
This commit is contained in:
@@ -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
@@ -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>
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user