chore: rebuild dev history without streaming-era commits

This commit is contained in:
zarzet committed 2026-02-27 13:48:44 +07:00
1 parent c89600591c
commit ab26d84632
111 files changed
+18282 -3459

No files matched your search

+2
View File
@@ -106,6 +106,8 @@ class CsvImportService {
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
artistId: trackData['artist_id']?.toString(),
albumId: trackData['album_id']?.toString(),
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
@@ -10,6 +10,7 @@ class DownloadRequestPayload {
final String outputDir;
final String filenameFormat;
final String quality;
final bool embedMetadata;
final bool embedLyrics;
final bool embedMaxQualityCover;
final int trackNumber;
@@ -47,6 +48,7 @@ class DownloadRequestPayload {
required this.outputDir,
required this.filenameFormat,
this.quality = 'LOSSLESS',
this.embedMetadata = true,
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.trackNumber = 1,
@@ -86,6 +88,7 @@ class DownloadRequestPayload {
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_metadata': embedMetadata,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
@@ -129,6 +132,7 @@ class DownloadRequestPayload {
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
embedMetadata: embedMetadata,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
trackNumber: trackNumber,
@@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver {
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _pendingPreviewValidation = <String>{};
static final Set<String> _failedExtract = <String>{};
static String cleanFilePath(String? filePath) {
@@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache[cleanPath];
if (cached != null) {
if (File(cached.previewPath).existsSync()) {
_touch(cleanPath, cached);
return cached.previewPath;
}
_cache.remove(cleanPath);
_cleanupTempCoverPathSync(cached.previewPath);
_touch(cleanPath, cached);
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
return cached.previewPath;
}
return null;
@@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache.remove(cleanPath);
_pendingExtract.remove(cleanPath);
_pendingRefresh.remove(cleanPath);
_pendingPreviewValidation.remove(cleanPath);
_failedExtract.remove(cleanPath);
if (cached != null) {
_cleanupTempCoverPathSync(cached.previewPath);
@@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver {
}
_pendingExtract.remove(oldestKey);
_pendingRefresh.remove(oldestKey);
_pendingPreviewValidation.remove(oldestKey);
_failedExtract.remove(oldestKey);
}
}
static void _validateCachedPreviewAsync(
String cleanPath,
_EmbeddedCoverCacheEntry entry, {
VoidCallback? onChanged,
}) {
if (_pendingPreviewValidation.contains(cleanPath)) return;
_pendingPreviewValidation.add(cleanPath);
Future.microtask(() async {
try {
final exists = await fileExists(entry.previewPath);
if (!exists) {
final latest = _cache[cleanPath];
if (latest != null && latest.previewPath == entry.previewPath) {
_cache.remove(cleanPath);
_failedExtract.remove(cleanPath);
onChanged?.call();
}
_cleanupTempCoverPathSync(entry.previewPath);
}
} finally {
_pendingPreviewValidation.remove(cleanPath);
}
});
}
static void _ensureCover(
String cleanPath, {
bool forceRefresh = false,
+463 -4
View File
@@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg');
class FFmpegService {
static const int _commandLogPreviewLength = 300;
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
static const Duration _liveTunnelStartupPollInterval = Duration(
milliseconds: 200,
);
static const Duration _liveTunnelStabilizationDelay = Duration(
milliseconds: 900,
);
static int _tempEmbedCounter = 0;
static FFmpegSession? _activeLiveDecryptSession;
static String? _activeLiveDecryptUrl;
static String? _activeLiveTempInputPath;
static String? _activeNativeDashManifestPath;
static String? _activeNativeDashManifestUrl;
static final Set<String> _preparedNativeDashManifestPaths = <String>{};
static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@@ -305,6 +320,433 @@ class FFmpegService {
return null;
}
static bool isActiveLiveDecryptedUrl(String url) {
final active = _activeLiveDecryptUrl;
if (active == null || active.isEmpty) return false;
return active == url.trim();
}
static bool isActiveNativeDashManifestUrl(String url) {
final activeUrl = _activeNativeDashManifestUrl;
if (activeUrl == null || activeUrl.isEmpty) return false;
final normalized = url.trim();
if (activeUrl == normalized) return true;
try {
final activePath = Uri.parse(activeUrl).toFilePath();
final incomingPath = Uri.parse(normalized).toFilePath();
return activePath == incomingPath;
} catch (_) {
return false;
}
}
static Future<String?> prepareTidalDashManifestForNativePlayback({
required String manifestPayload,
bool registerAsActive = true,
}) async {
final rawPayload = manifestPayload.trim();
if (rawPayload.isEmpty) return null;
final payload = rawPayload.startsWith('MANIFEST:')
? rawPayload.substring('MANIFEST:'.length)
: rawPayload;
final manifestPath = await _writeTempManifestFile(payload);
if (manifestPath == null) {
_log.e('Failed to prepare Tidal DASH manifest for native playback');
return null;
}
final manifestUrl = Uri.file(manifestPath).toString();
_preparedNativeDashManifestPaths.add(manifestPath);
if (registerAsActive) {
await activatePreparedNativeDashManifest(manifestUrl);
}
return manifestUrl;
}
static Future<void> activatePreparedNativeDashManifest(String url) async {
final normalized = url.trim();
if (normalized.isEmpty) return;
final manifestPath = _nativeDashManifestPathFromUrl(normalized);
if (manifestPath == null ||
!_preparedNativeDashManifestPaths.contains(manifestPath)) {
return;
}
final previousPath = _activeNativeDashManifestPath;
_activeNativeDashManifestPath = manifestPath;
_activeNativeDashManifestUrl = Uri.file(manifestPath).toString();
if (previousPath != null &&
previousPath.isNotEmpty &&
previousPath != manifestPath) {
_preparedNativeDashManifestPaths.remove(previousPath);
await _deleteNativeDashManifestFile(previousPath);
}
}
static Future<void> stopNativeDashManifestPlayback() async {
final manifestPath = _activeNativeDashManifestPath;
_activeNativeDashManifestPath = null;
_activeNativeDashManifestUrl = null;
if (manifestPath == null || manifestPath.isEmpty) return;
_preparedNativeDashManifestPaths.remove(manifestPath);
await _deleteNativeDashManifestFile(manifestPath);
}
static Future<void> cleanupInactivePreparedNativeDashManifests() async {
final activePath = _activeNativeDashManifestPath;
final stalePaths = _preparedNativeDashManifestPaths
.where((path) => path != activePath)
.toList(growable: false);
for (final path in stalePaths) {
_preparedNativeDashManifestPaths.remove(path);
await _deleteNativeDashManifestFile(path);
}
}
static String? _nativeDashManifestPathFromUrl(String url) {
try {
final uri = Uri.parse(url);
if (uri.scheme.toLowerCase() != 'file') {
return null;
}
final path = uri.toFilePath();
return path.trim().isEmpty ? null : path;
} catch (_) {
return null;
}
}
static Future<void> _deleteNativeDashManifestFile(String path) async {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
static Future<void> stopLiveDecryptedStream() async {
final session = _activeLiveDecryptSession;
final tempInputPath = _activeLiveTempInputPath;
_activeLiveDecryptSession = null;
_activeLiveDecryptUrl = null;
_activeLiveTempInputPath = null;
if (session != null) {
try {
await session.cancel();
} catch (e) {
final sessionId = session.getSessionId();
if (sessionId != null) {
try {
await FFmpegKit.cancel(sessionId);
} catch (_) {}
}
_log.w('Failed to stop live decrypt session cleanly: $e');
}
}
if (tempInputPath != null && tempInputPath.isNotEmpty) {
try {
final file = File(tempInputPath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
}
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
required String manifestPayload,
String preferredFormat = 'm4a',
}) async {
final rawPayload = manifestPayload.trim();
if (rawPayload.isEmpty) return null;
final payload = rawPayload.startsWith('MANIFEST:')
? rawPayload.substring('MANIFEST:'.length)
: rawPayload;
final manifestPath = await _writeTempManifestFile(payload);
if (manifestPath == null) {
_log.e('Failed to prepare Tidal DASH manifest for live stream');
return null;
}
await stopLiveDecryptedStream();
await stopNativeDashManifestPlayback();
final attempts = _buildLiveDashFormatAttempts(preferredFormat);
for (final format in attempts) {
final stream = await _tryStartLiveDashAttempt(
manifestPath: manifestPath,
format: format,
);
if (stream != null) {
_activeLiveDecryptSession = stream.session;
_activeLiveDecryptUrl = stream.localUrl;
_activeLiveTempInputPath = manifestPath;
return stream;
}
}
try {
final file = File(manifestPath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
return null;
}
static Future<String?> _writeTempManifestFile(String payload) async {
if (payload.trim().isEmpty) return null;
Uint8List bytes;
try {
bytes = base64Decode(payload);
} catch (_) {
bytes = Uint8List.fromList(utf8.encode(payload));
}
final manifestText = utf8.decode(bytes, allowMalformed: true).trim();
if (manifestText.isEmpty) return null;
final tempDir = await getTemporaryDirectory();
final manifestPath =
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
await File(manifestPath).writeAsString(manifestText, flush: true);
return manifestPath;
}
static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts(
String preferredFormat,
) {
final normalized = preferredFormat.trim().toLowerCase();
if (normalized == 'flac') {
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
}
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
}
static Future<bool> _awaitLiveTunnelReady(FFmpegSession session) async {
final deadline = DateTime.now().add(_liveTunnelStartupTimeout);
var seenRunning = false;
while (DateTime.now().isBefore(deadline)) {
final state = await session.getState();
if (state == SessionState.running) {
seenRunning = true;
break;
}
if (state != SessionState.created) {
return false;
}
await Future<void>.delayed(_liveTunnelStartupPollInterval);
}
if (!seenRunning) {
return false;
}
await Future<void>.delayed(_liveTunnelStabilizationDelay);
return (await session.getState()) == SessionState.running;
}
static Future<LiveDecryptedStreamResult?> _tryStartLiveDashAttempt({
required String manifestPath,
required _LiveDecryptFormat format,
}) async {
final port = await _allocateLoopbackPort();
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
final mimeType = format == _LiveDecryptFormat.flac
? 'audio/flac'
: 'audio/mp4';
final localUrl = 'http://localhost:$port/stream.$ext';
final commandArguments = <String>[
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-protocol_whitelist',
'file,http,https,tcp,tls,crypto,data',
'-i',
manifestPath,
'-map',
'0:a:0',
'-c:a',
'copy',
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
if (format == _LiveDecryptFormat.m4a) ...[
'-movflags',
'+frag_keyframe+empty_moov+default_base_moof',
'-f',
'mp4',
],
'-content_type',
mimeType,
'-listen',
'1',
localUrl,
];
_log.d(
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
);
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
final isReady = await _awaitLiveTunnelReady(session);
if (isReady) {
return LiveDecryptedStreamResult(
localUrl: localUrl,
format: ext,
session: session,
);
}
final state = await session.getState();
final output = (await session.getOutput() ?? '').trim();
if (output.isNotEmpty) {
_log.w('Tidal DASH tunnel failed ($ext): $output');
} else {
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
}
try {
await session.cancel();
} catch (_) {}
return null;
}
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
required String encryptedStreamUrl,
required String decryptionKey,
String preferredFormat = 'flac',
}) async {
final inputUrl = encryptedStreamUrl.trim();
if (inputUrl.isEmpty) return null;
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates for live stream');
return null;
}
await stopLiveDecryptedStream();
final attempts = _buildLiveDecryptFormatAttempts(preferredFormat);
for (final format in attempts) {
for (final keyCandidate in keyCandidates) {
final stream = await _tryStartLiveDecryptAttempt(
inputUrl: inputUrl,
decryptionKey: keyCandidate,
format: format,
);
if (stream != null) {
_activeLiveDecryptSession = stream.session;
_activeLiveDecryptUrl = stream.localUrl;
_activeLiveTempInputPath = null;
return stream;
}
}
}
return null;
}
static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts(
String preferredFormat,
) {
final normalized = preferredFormat.trim().toLowerCase();
if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') {
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
}
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
}
static Future<LiveDecryptedStreamResult?> _tryStartLiveDecryptAttempt({
required String inputUrl,
required String decryptionKey,
required _LiveDecryptFormat format,
}) async {
final port = await _allocateLoopbackPort();
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
final mimeType = format == _LiveDecryptFormat.flac
? 'audio/flac'
: 'audio/mp4';
final localUrl = 'http://localhost:$port/stream.$ext';
final commandArguments = <String>[
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-decryption_key',
decryptionKey,
'-i',
inputUrl,
'-map',
'0:a:0',
'-c:a',
'copy',
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
if (format == _LiveDecryptFormat.m4a) ...[
'-movflags',
'+frag_keyframe+empty_moov+default_base_moof',
'-f',
'mp4',
],
'-content_type',
mimeType,
'-listen',
'1',
localUrl,
];
_log.d(
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
);
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
final isReady = await _awaitLiveTunnelReady(session);
if (isReady) {
return LiveDecryptedStreamResult(
localUrl: localUrl,
format: ext,
session: session,
);
}
final state = await session.getState();
final output = (await session.getOutput() ?? '').trim();
if (output.isNotEmpty) {
_log.w('Live decrypt attempt failed ($ext): $output');
} else {
_log.w('Live decrypt attempt failed ($ext) with session state: $state');
}
try {
await session.cancel();
} catch (_) {}
return null;
}
static Future<int> _allocateLoopbackPort() async {
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = socket.port;
await socket.close();
return port;
}
static Future<String?> convertFlacToOpus(
String inputPath, {
String bitrate = '128k',
@@ -861,9 +1303,10 @@ class FFmpegService {
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
switch (key) {
switch (normalizedKey) {
case 'TITLE':
id3Map['title'] = value;
break;
@@ -878,10 +1321,12 @@ class FFmpegService {
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
id3Map['disc'] = value;
break;
case 'DATE':
@@ -921,3 +1366,17 @@ class FFmpegResult {
required this.output,
});
}
enum _LiveDecryptFormat { flac, m4a }
class LiveDecryptedStreamResult {
final String localUrl;
final String format;
final FFmpegSession session;
LiveDecryptedStreamResult({
required this.localUrl,
required this.format,
required this.session,
});
}
+52
View File
@@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge');
class PlatformBridge {
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
static const _downloadProgressEvents = EventChannel(
'com.zarz.spotiflac/download_progress_stream',
);
static const _libraryScanProgressEvents = EventChannel(
'com.zarz.spotiflac/library_scan_progress_stream',
);
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url');
@@ -48,6 +54,17 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
String artistId, {
int limit = 12,
}) async {
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
'artist_id': artistId,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
@@ -113,6 +130,18 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Stream<Map<String, dynamic>> downloadProgressStream() {
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
if (event is String) {
return jsonDecode(event) as Map<String, dynamic>;
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
}
static Future<void> initItemProgress(String itemId) async {
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
}
@@ -532,6 +561,17 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
String artistId, {
int limit = 12,
}) async {
final result = await _channel.invokeMethod('getDeezerRelatedArtists', {
'artist_id': artistId,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getDeezerMetadata(
String resourceType,
String resourceId,
@@ -1098,6 +1138,18 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
if (event is String) {
return jsonDecode(event) as Map<String, dynamic>;
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
}
/// Cancel ongoing library scan
static Future<void> cancelLibraryScan() async {
await _channel.invokeMethod('cancelLibraryScan');
@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
class ShellNavigationService {
static final GlobalKey<NavigatorState> homeTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
GlobalKey<NavigatorState>();
static int _currentTabIndex = 0;
static bool _showStoreTab = false;
static void syncState({
required int currentTabIndex,
required bool showStoreTab,
}) {
_currentTabIndex = currentTabIndex;
_showStoreTab = showStoreTab;
}
static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
if (_showStoreTab && _currentTabIndex == 2) {
return storeTabNavigatorKey.currentState;
}
return null;
}
}
+2 -45
View File
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -28,36 +27,6 @@ class UpdateChecker {
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
static Future<String> _getDeviceArch() async {
if (!Platform.isAndroid) return 'unknown';
try {
final cpuInfo = await File('/proc/cpuinfo').readAsString();
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
return 'arm64';
}
final result = await Process.run('uname', ['-m']);
final arch = result.stdout.toString().trim().toLowerCase();
if (arch.contains('aarch64') || arch.contains('arm64')) {
return 'arm64';
} else if (arch.contains('armv7') || arch.contains('arm')) {
return 'arm32';
} else if (arch.contains('x86_64')) {
return 'x86_64';
} else if (arch.contains('x86') || arch.contains('i686')) {
return 'x86';
}
return 'arm64';
} catch (e) {
_log.e('Error detecting arch: $e');
return 'arm64';
}
}
/// Check for updates based on channel preference
/// [channel] can be 'stable' or 'preview'
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
@@ -109,11 +78,7 @@ class UpdateChecker {
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
final deviceArch = await _getDeviceArch();
_log.d('Device architecture: $deviceArch');
String? arm64Url;
String? arm32Url;
String? universalUrl;
final assets = releaseData['assets'] as List<dynamic>? ?? [];
@@ -128,22 +93,14 @@ class UpdateChecker {
}
if (name.contains('arm64') || name.contains('v8a')) {
arm64Url = downloadUrl;
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
arm32Url = downloadUrl;
} else if (name.contains('universal')) {
universalUrl = downloadUrl;
}
}
}
String? apkUrl;
if (deviceArch == 'arm64') {
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
} else if (deviceArch == 'arm32') {
apkUrl = arm32Url ?? universalUrl;
} else {
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
}
// Only arm64 is supported; fall back to universal if available
final apkUrl = arm64Url ?? universalUrl;
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');