Compare commits

..
2 Commits
Author SHA1 Message Date
RarDog 402a5436bc fix(pawchive): fix favorites API endpoints and login 404 error (v2.3.9) 2026-09-04 15:42:03 +03:00
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
9 changed files with 157 additions and 12 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.9',
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
bullets: [
'Исправлены API эндпоинты Pawchive: пути запросов избранных авторов и управления подписками приведены в соответствие со спецификацией API v1 (/api/v1/account/favorites, /api/v1/favorites/creator/...).',
'Устранена ошибка 404: авторизация по логину и паролю, а также по session cookie теперь успешно завершается и сразу синхронизирует всех авторов.',
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
],
),
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;
@@ -3,7 +3,6 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../di/backend_providers.dart';
import '../models/favorite_artist_item.dart'; import '../models/favorite_artist_item.dart';
import '../models/pawchive_account.dart'; import '../models/pawchive_account.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -98,10 +97,25 @@ class PawchiveSyncService {
} }
// Check if redirect points back to login with flash error // Check if redirect points back to login with flash error
if (redirectLocation.contains('/account/login')) { if (redirectLocation.contains('/account/login') ||
redirectLocation.contains('/login')) {
throw StateError('Неверный логин или пароль'); throw StateError('Неверный логин или пароль');
} }
// Check for flash errors in session cookie if present
try {
final dotIdx = sessionValue.indexOf('.');
final payloadBase64 =
dotIdx != -1 ? sessionValue.substring(0, dotIdx) : sessionValue;
final normalizedB64 = base64Url.normalize(payloadBase64);
final decoded = utf8.decode(base64Url.decode(normalizedB64));
if (decoded.contains('is incorrect') || decoded.contains('_flashes')) {
throw StateError('Неверный логин или пароль');
}
} catch (e) {
if (e is StateError) rethrow;
}
// Verify session and fetch initial artists // Verify session and fetch initial artists
final remoteArtists = await fetchRemoteFavoriteArtists( final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: normalizedBase, baseUrl: normalizedBase,
@@ -118,6 +132,15 @@ class PawchiveSyncService {
isActive: true, isActive: true,
syncedArtistsCount: remoteArtists.length, syncedArtistsCount: remoteArtists.length,
); );
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Неверный логин или пароль');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) { } catch (e) {
if (e is StateError || e is ArgumentError) rethrow; if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Ошибка входа в Pawchive: $e'); throw StateError('Ошибка входа в Pawchive: $e');
@@ -156,7 +179,17 @@ class PawchiveSyncService {
isActive: true, isActive: true,
syncedArtistsCount: remoteArtists.length, syncedArtistsCount: remoteArtists.length,
); );
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Сессия недействительна или истекла');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) { } catch (e) {
if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Сессия недействительна или истекла: $e'); throw StateError('Сессия недействительна или истекла: $e');
} }
} }
@@ -172,7 +205,7 @@ class PawchiveSyncService {
: baseUrl; : baseUrl;
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'$normalizedBase/account/favorites', '$normalizedBase/api/v1/account/favorites',
queryParameters: {'type': 'artist'}, queryParameters: {'type': 'artist'},
options: Options( options: Options(
headers: { headers: {
@@ -180,9 +213,17 @@ class PawchiveSyncService {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
}, },
validateStatus: (status) => status != null && status < 500,
), ),
); );
if (response.statusCode == 401 || response.statusCode == 403) {
throw StateError('Сессия истекла или недействительна');
}
if (response.statusCode != 200) {
throw StateError('Ошибка сервера Pawchive (HTTP ${response.statusCode})');
}
final data = response.data; final data = response.data;
if (data is! List) return const []; if (data is! List) return const [];
@@ -344,7 +385,7 @@ class PawchiveSyncService {
? account.baseUrl.substring(0, account.baseUrl.length - 1) ? account.baseUrl.substring(0, account.baseUrl.length - 1)
: account.baseUrl; : account.baseUrl;
final path = '$normalizedBase/favorites/creator/$service/$artistId'; final path = '$normalizedBase/api/v1/favorites/creator/$service/$artistId';
final options = Options( final options = Options(
headers: { headers: {
'Cookie': 'session=$cleanCookie', 'Cookie': 'session=$cleanCookie',
+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.9+49
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+2 -2
View File
@@ -253,7 +253,7 @@ void main() {
); );
expect(addResult, isTrue); expect(addResult, isTrue);
expect(lastMethod, equals('POST')); expect(lastMethod, equals('POST'));
expect(lastPath, equals('https://pawchive.pw/favorites/creator/fanbox/12345')); expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
final removeResult = await service.toggleRemoteFavorite( final removeResult = await service.toggleRemoteFavorite(
account: account, account: account,
@@ -263,7 +263,7 @@ void main() {
); );
expect(removeResult, isTrue); expect(removeResult, isTrue);
expect(lastMethod, equals('DELETE')); expect(lastMethod, equals('DELETE'));
expect(lastPath, equals('https://pawchive.pw/favorites/creator/fanbox/12345')); expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
}); });
}); });
} }