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
209 lines
5.9 KiB
Dart
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;
|
|
}
|