Files
Qlyra/lib/core/utils/update_installer.dart
sevenhill 50816588e4
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Update Qlyra application
2026-08-30 22:53:15 +03:00

209 lines
5.9 KiB
Dart

import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:open_filex/open_filex.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'update_checker.dart';
enum UpdateInstallStatus {
done,
noAsset,
downloadFailed,
integrityFailed,
installFailed,
}
class UpdateInstallResult {
final UpdateInstallStatus status;
final String? error;
const UpdateInstallResult(this.status, {this.error});
bool get ok => status == UpdateInstallStatus.done;
}
abstract class UpdateInstaller {
static const MethodChannel _integrityChannel = MethodChannel(
'ru.qlyra.app/update_integrity',
);
static bool get isSupported => Platform.isAndroid;
static Future<UpdateInstallResult> downloadAndInstall(
AppUpdateInfo info, {
void Function(double progress)? onProgress,
}) async {
final artifact = await resolveApkArtifact(info);
if (artifact == null) {
return const UpdateInstallResult(UpdateInstallStatus.noAsset);
}
File file;
try {
file = await _download(artifact, info.tag, onProgress);
} on UpdateIntegrityException catch (e) {
return UpdateInstallResult(
UpdateInstallStatus.integrityFailed,
error: e.message,
);
} catch (e) {
return UpdateInstallResult(
UpdateInstallStatus.downloadFailed,
error: e.toString(),
);
}
final signerMatches = await _verifySigner(file.path);
if (!signerMatches) {
await file.delete();
return const UpdateInstallResult(
UpdateInstallStatus.integrityFailed,
error: 'APK signer does not match the installed application',
);
}
final opened = await OpenFilex.open(
file.path,
type: 'application/vnd.android.package-archive',
);
if (opened.type != ResultType.done) {
return UpdateInstallResult(
UpdateInstallStatus.installFailed,
error: opened.message,
);
}
return const UpdateInstallResult(UpdateInstallStatus.done);
}
static Future<AppUpdateArtifact?> resolveApkArtifact(
AppUpdateInfo info,
) async {
if (!Platform.isAndroid || info.artifacts.isEmpty) return null;
final packageInfo = await PackageInfo.fromPlatform();
final androidInfo = await DeviceInfoPlugin().androidInfo;
return selectArtifact(
info.artifacts,
packageInfo.packageName,
androidInfo.supportedAbis,
);
}
static AppUpdateArtifact? selectArtifact(
List<AppUpdateArtifact> artifacts,
String packageName,
List<String> supportedAbis,
) {
final flavor = UpdateChecker.manifestSlugForPackageName(packageName);
final matching = artifacts.where((item) => item.flavor == flavor).toList();
for (final abi in supportedAbis) {
for (final artifact in matching) {
if (artifact.abi == abi) return artifact;
}
}
for (final artifact in matching) {
if (artifact.abi == 'universal') return artifact;
}
return null;
}
static Future<File> _download(
AppUpdateArtifact artifact,
String tag,
void Function(double progress)? onProgress,
) async {
final dir = await getTemporaryDirectory();
final safeTag = tag.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
final file = File('${dir.path}/qlyra-update-$safeTag.apk');
final part = File('${file.path}.part');
final client = HttpClient();
try {
final uri = Uri.parse(artifact.url);
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'QlyraUpdateInstaller');
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
await response.drain<void>();
throw HttpException('HTTP ${response.statusCode}', uri: uri);
}
final total = response.contentLength;
if (total >= 0 && total != artifact.sizeBytes) {
await response.drain<void>();
throw UpdateIntegrityException('Unexpected Content-Length: $total');
}
var received = 0;
final sink = part.openWrite();
try {
await for (final chunk in response) {
received += chunk.length;
if (received > artifact.sizeBytes) {
throw const UpdateIntegrityException('APK is larger than expected');
}
sink.add(chunk);
if (onProgress != null) {
onProgress(received / artifact.sizeBytes);
}
}
} finally {
await sink.close();
}
if (received != artifact.sizeBytes) {
throw UpdateIntegrityException('Unexpected APK size: $received bytes');
}
await verifyArtifact(part, artifact);
if (await file.exists()) await file.delete();
await part.rename(file.path);
return file;
} catch (e) {
if (await part.exists()) {
try {
await part.delete();
} catch (_) {}
}
rethrow;
} finally {
client.close();
}
}
static Future<bool> _verifySigner(String path) async {
try {
return await _integrityChannel.invokeMethod<bool>('verifyApkSigner', {
'path': path,
}) ??
false;
} on PlatformException {
return false;
}
}
static Future<void> verifyArtifact(
File file,
AppUpdateArtifact artifact,
) async {
final size = await file.length();
if (size != artifact.sizeBytes) {
throw UpdateIntegrityException('Unexpected APK size: $size bytes');
}
final digest = await sha256.bind(file.openRead()).first;
if (digest.toString().toLowerCase() != artifact.sha256.toLowerCase()) {
throw const UpdateIntegrityException('APK SHA-256 mismatch');
}
}
}
class UpdateIntegrityException implements Exception {
final String message;
const UpdateIntegrityException(this.message);
@override
String toString() => message;
}