Compare commits

...
2 Commits
Author SHA1 Message Date
RarDog 80ca59d19f 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
2026-09-04 14:55:50 +03:00
RarDog 848fee6ff1 fix: lazy-resolve image dimensions for providers without width/height metadata (Pawchive)
- Convert _PostInfoCard to StatefulWidget
- Lazily decode real pixel dimensions (e.g. 1920×1080) from network when post.width==0 && post.height==0
- Show '… × …' while resolving, actual size once decoded
- Hide resolution badge for video/link posts where dimensions can't be resolved
- In-memory cache prevents redundant re-downloads
- Bump version to 2.3.7+47
2026-09-04 14:42:07 +03:00
8 changed files with 212 additions and 17 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) {
+19
View File
@@ -1,4 +1,23 @@
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(
version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
bullets: [
'Auto-Resolve Image Dimensions: when a provider (such as Pawchive) does not supply width/height metadata, the app now lazily resolves the real pixel dimensions by decoding the image in the background and displays them as soon as they are ready.',
'No More "0 × 0": the resolution badge now shows "… × …" while dimensions are being fetched, and the correct resolution (e.g. 1920 × 1080) once decoded. The badge is hidden entirely for video/link posts where dimensions cannot be resolved.',
'Session Cache: resolved image dimensions are cached in memory for the lifetime of the app so repeated visits to the same post do not trigger re-downloads.',
],
),
LunarisChange( LunarisChange(
version: '2.3.6', version: '2.3.6',
title: 'Pawchive Accounts Integration & Favorites Synchronization', title: 'Pawchive Accounts Integration & Favorites Synchronization',
+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;
@@ -1185,7 +1185,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
} }
} }
class _PostInfoCard extends StatelessWidget { class _PostInfoCard extends StatefulWidget {
const _PostInfoCard({ const _PostInfoCard({
required this.post, required this.post,
required this.strings, required this.strings,
@@ -1198,8 +1198,90 @@ class _PostInfoCard extends StatelessWidget {
final DownloadedMedia? localMedia; final DownloadedMedia? localMedia;
final int? fileSizeBytes; final int? fileSizeBytes;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
}
class _PostInfoCardState extends State<_PostInfoCard> {
// Lazily resolved dimensions (used when post.width/height == 0).
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
static final _dimensionCache = <String, (int, int)>{};
static bool _isImageType(String fileType) {
const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
return imageTypes.contains(fileType.toLowerCase());
}
@override
void initState() {
super.initState();
_maybeResolveDimensions();
}
@override
void didUpdateWidget(_PostInfoCard old) {
super.didUpdateWidget(old);
if (old.post.fileUrl != widget.post.fileUrl) {
_resolvedWidth = null;
_resolvedHeight = null;
_resolvingDimensions = false;
_maybeResolveDimensions();
}
}
void _maybeResolveDimensions() {
final post = widget.post;
// Only attempt lazy resolution when dimensions are unknown (0) and it's
// an image (not video/link which we can't resolve this way).
if (post.width != 0 || post.height != 0) return;
if (!_isImageType(post.fileType)) return;
final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
if (url.isEmpty) return;
// Check cache first.
if (_dimensionCache.containsKey(url)) {
final cached = _dimensionCache[url]!;
_resolvedWidth = cached.$1;
_resolvedHeight = cached.$2;
return;
}
if (_resolvingDimensions) return;
_resolvingDimensions = true;
final imageProvider = NetworkImage(url);
final completer = imageProvider.resolve(ImageConfiguration.empty);
completer.addListener(
ImageStreamListener(
(info, _) {
final w = info.image.width;
final h = info.image.height;
_dimensionCache[url] = (w, h);
if (mounted) {
setState(() {
_resolvedWidth = w;
_resolvedHeight = h;
_resolvingDimensions = false;
});
}
},
onError: (_, __) {
if (mounted) setState(() => _resolvingDimensions = false);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post;
final strings = widget.strings;
final localMedia = widget.localMedia;
final fileSizeBytes = widget.fileSizeBytes;
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ?? final artists = post.tagGroups['artist'] ??
@@ -1208,6 +1290,14 @@ class _PostInfoCard extends StatelessWidget {
.map((t) => t.replaceFirst('artist:', '')) .map((t) => t.replaceFirst('artist:', ''))
.toList(); .toList();
// Determine which dimensions to display.
final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
final hasDimensions = displayWidth != null && displayHeight != null;
final dimensionLabel = hasDimensions
? '$displayWidth × $displayHeight'
: (_resolvingDimensions ? '… × …' : null);
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1293,7 +1383,7 @@ class _PostInfoCard extends StatelessWidget {
), ),
), ),
Text( Text(
_formatDate(post.createdAt), _formatPostDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
@@ -1321,8 +1411,8 @@ class _PostInfoCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
fileSizeBytes != null && fileSizeBytes! > 0 fileSizeBytes != null && fileSizeBytes > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!) ? DownloadedMediaService.formatBytes(fileSizeBytes)
: (strings.ru ? 'Офлайн' : 'Offline'), : (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer, color: scheme.onPrimaryContainer,
@@ -1341,10 +1431,11 @@ class _PostInfoCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_SpecBadge( if (dimensionLabel != null)
icon: Icons.aspect_ratio_rounded, _SpecBadge(
label: '${post.width} × ${post.height}', icon: Icons.aspect_ratio_rounded,
), label: dimensionLabel,
),
if (post.fileType.isNotEmpty) if (post.fileType.isNotEmpty)
_SpecBadge( _SpecBadge(
icon: Icons.insert_drive_file_outlined, icon: Icons.insert_drive_file_outlined,
@@ -1373,10 +1464,10 @@ class _PostInfoCard extends StatelessWidget {
), ),
); );
} }
}
static String _formatDate(DateTime date) { String _formatPostDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
} }
class _SpecBadge extends StatelessWidget { class _SpecBadge extends StatelessWidget {
@@ -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.6+46 version: 2.3.8+48
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"