feat: ABI-aware auto-update — pick correct per-ABI APK from Gitea

- Android: expose getSupportedAbis/getPrimaryAbi via rulegel/device channel (Build.SUPPORTED_ABIS)
- AppUpdateInfo: add apkArm64Url, apkArmv7Url, apkX86_64Url fields + apkUrlForAbi() selector
- UpdateService: parse per-ABI APK assets by filename pattern, async assetUrlForCurrentPlatform
- app.dart + settings_screen.dart: await new async URL resolver
- Bump version to 2.3.8+48
This commit is contained in:
2026-09-04 14:55:50 +03:00
parent 848fee6ff1
commit 80ca59d19f
7 changed files with 101 additions and 6 deletions
@@ -54,6 +54,8 @@ class MainActivity : FlutterActivity() {
setPreferredRefreshRate(hz.toFloat()) setPreferredRefreshRate(hz.toFloat())
result.success(null) result.success(null)
} }
"getSupportedAbis" -> result.success(Build.SUPPORTED_ABIS.toList())
"getPrimaryAbi" -> result.success(Build.SUPPORTED_ABIS.firstOrNull() ?: "armeabi-v7a")
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (error: Throwable) { } catch (error: Throwable) {
+1 -1
View File
@@ -149,7 +149,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
Future<void> _downloadUpdate(AppUpdateInfo info) async { Future<void> _downloadUpdate(AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
+10
View File
@@ -1,4 +1,14 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.8',
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
bullets: [
'Определение архитектуры Android: приложение читает Build.SUPPORTED_ABIS (arm64-v8a, armeabi-v7a, x86_64) через нативный канал.',
'Точный APK из Gitea: при обновлении скачивается per-ABI APK (app-arm64-v8a-release.apk и т.д.), а не один общий файл.',
'Fallback: нет per-ABI APK → универсальный APK → страница релиза в браузере.',
'Кеш ABI: архитектура определяется один раз за сеанс.',
],
),
LunarisChange( LunarisChange(
version: '2.3.7', version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers', title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
+26
View File
@@ -7,6 +7,9 @@ class AppUpdateInfo {
required this.htmlUrl, required this.htmlUrl,
required this.publishedAt, required this.publishedAt,
this.apkUrl, this.apkUrl,
this.apkArm64Url,
this.apkArmv7Url,
this.apkX86_64Url,
this.windowsInstallerUrl, this.windowsInstallerUrl,
this.portableZipUrl, this.portableZipUrl,
this.linuxTarGzUrl, this.linuxTarGzUrl,
@@ -18,13 +21,36 @@ class AppUpdateInfo {
final String body; final String body;
final String htmlUrl; final String htmlUrl;
final DateTime? publishedAt; final DateTime? publishedAt;
/// Generic APK (fat / universal build). Used as a fallback.
final String? apkUrl; final String? apkUrl;
/// Per-ABI APKs produced by `flutter build apk --split-per-abi`.
final String? apkArm64Url; // app-arm64-v8a-release.apk
final String? apkArmv7Url; // app-armeabi-v7a-release.apk
final String? apkX86_64Url; // app-x86_64-release.apk
final String? windowsInstallerUrl; final String? windowsInstallerUrl;
final String? portableZipUrl; final String? portableZipUrl;
final String? linuxTarGzUrl; final String? linuxTarGzUrl;
/// Returns the best APK URL for [abi], falling back to the generic APK.
String? apkUrlForAbi(String abi) {
if (abi.contains('arm64') || abi == 'arm64-v8a') {
return apkArm64Url ?? apkUrl;
}
if (abi.contains('x86_64')) {
return apkX86_64Url ?? apkUrl;
}
// armeabi-v7a and everything else
return apkArmv7Url ?? apkArm64Url ?? apkUrl;
}
bool get hasAssets => bool get hasAssets =>
apkUrl != null || apkUrl != null ||
apkArm64Url != null ||
apkArmv7Url != null ||
apkX86_64Url != null ||
windowsInstallerUrl != null || windowsInstallerUrl != null ||
portableZipUrl != null || portableZipUrl != null ||
linuxTarGzUrl != null; linuxTarGzUrl != null;
+60 -3
View File
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import '../../app/app_version.dart'; import '../../app/app_version.dart';
import '../../core/errors/failure.dart'; import '../../core/errors/failure.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
@@ -15,9 +17,39 @@ class UpdateService {
static const releasesUrl = static const releasesUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases'; 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
static const _deviceChannel = MethodChannel('rulegel/device');
final Dio _dio; final Dio _dio;
final SettingsService _settingsService; final SettingsService _settingsService;
// Cached primary ABI (e.g. "arm64-v8a").
String? _cachedPrimaryAbi;
/// Returns the primary ABI of the running device.
/// On non-Android platforms returns an empty string.
Future<String> getPrimaryAbi() async {
if (_cachedPrimaryAbi != null) return _cachedPrimaryAbi!;
if (!Platform.isAndroid) return '';
try {
final abi = await _deviceChannel.invokeMethod<String>('getPrimaryAbi');
_cachedPrimaryAbi = abi ?? 'armeabi-v7a';
} catch (_) {
_cachedPrimaryAbi = 'armeabi-v7a';
}
return _cachedPrimaryAbi!;
}
/// Returns all supported ABIs of the running device.
Future<List<String>> getSupportedAbis() async {
if (!Platform.isAndroid) return const [];
try {
final abis = await _deviceChannel.invokeMethod<List<Object?>>('getSupportedAbis');
return abis?.whereType<String>().toList() ?? const [];
} catch (_) {
return const [];
}
}
Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async { Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async {
final settingsResult = await _settingsService.getSettings(); final settingsResult = await _settingsService.getSettings();
final settings = settingsResult is Success<AppSettings> final settings = settingsResult is Success<AppSettings>
@@ -97,21 +129,40 @@ class UpdateService {
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) { AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
final tag = (json['tag_name'] ?? '').toString(); final tag = (json['tag_name'] ?? '').toString();
final assets = (json['assets'] as List?) ?? const []; final assets = (json['assets'] as List?) ?? const [];
String? apkUrl; String? apkUrl;
String? apkArm64Url;
String? apkArmv7Url;
String? apkX86_64Url;
String? installerUrl; String? installerUrl;
String? portableUrl; String? portableUrl;
String? linuxTarGzUrl; String? linuxTarGzUrl;
for (final item in assets.whereType<Map>()) { for (final item in assets.whereType<Map>()) {
final name = (item['name'] ?? '').toString().toLowerCase(); final name = (item['name'] ?? '').toString().toLowerCase();
final url = (item['browser_download_url'] ?? '').toString(); final url = (item['browser_download_url'] ?? '').toString();
if (url.isEmpty) continue; if (url.isEmpty) continue;
if (name.endsWith('.apk')) apkUrl = url;
if (name.endsWith('.apk')) {
// Detect per-ABI APKs by name pattern produced by --split-per-abi.
if (name.contains('arm64-v8a') || name.contains('arm64_v8a')) {
apkArm64Url = url;
} else if (name.contains('armeabi-v7a') || name.contains('armeabi_v7a')) {
apkArmv7Url = url;
} else if (name.contains('x86_64')) {
apkX86_64Url = url;
} else {
// Universal / fat APK
apkUrl = url;
}
}
if (name.endsWith('.exe')) installerUrl = url; if (name.endsWith('.exe')) installerUrl = url;
if (name.endsWith('.zip')) portableUrl = url; if (name.endsWith('.zip')) portableUrl = url;
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) { if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
linuxTarGzUrl = url; linuxTarGzUrl = url;
} }
} }
return AppUpdateInfo( return AppUpdateInfo(
version: _versionFromTag(tag), version: _versionFromTag(tag),
tagName: tag, tagName: tag,
@@ -120,15 +171,21 @@ class UpdateService {
htmlUrl: (json['html_url'] ?? '').toString(), htmlUrl: (json['html_url'] ?? '').toString(),
publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()), publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()),
apkUrl: apkUrl, apkUrl: apkUrl,
apkArm64Url: apkArm64Url,
apkArmv7Url: apkArmv7Url,
apkX86_64Url: apkX86_64Url,
windowsInstallerUrl: installerUrl, windowsInstallerUrl: installerUrl,
portableZipUrl: portableUrl, portableZipUrl: portableUrl,
linuxTarGzUrl: linuxTarGzUrl, linuxTarGzUrl: linuxTarGzUrl,
); );
} }
String? assetUrlForCurrentPlatform(AppUpdateInfo info) { /// Returns the download URL for the current platform/ABI.
/// Pass [abi] (e.g. from [getPrimaryAbi]) on Android for per-ABI selection.
Future<String?> assetUrlForCurrentPlatform(AppUpdateInfo info) async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return info.apkUrl; final abi = await getPrimaryAbi();
return info.apkUrlForAbi(abi);
} }
if (Platform.isLinux) { if (Platform.isLinux) {
return info.linuxTarGzUrl ?? info.portableZipUrl; return info.linuxTarGzUrl ?? info.portableZipUrl;
@@ -1030,7 +1030,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async { Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.3.7+47 version: 2.3.8+48
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"