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 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 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 artifacts, String packageName, List 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 _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(); throw HttpException('HTTP ${response.statusCode}', uri: uri); } final total = response.contentLength; if (total >= 0 && total != artifact.sizeBytes) { await response.drain(); 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 _verifySigner(String path) async { try { return await _integrityChannel.invokeMethod('verifyApkSigner', { 'path': path, }) ?? false; } on PlatformException { return false; } } static Future 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; }