feat: extension runtime and provider improvements

This commit is contained in:
zarzet
2026-06-26 04:12:26 +07:00
parent 21347420f3
commit ee35f52baf
17 changed files with 1019 additions and 56 deletions
+10 -1
View File
@@ -12,7 +12,14 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
verificationRequired,
}
@JsonSerializable()
class DownloadItem {
@@ -94,6 +101,8 @@ class DownloadItem {
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
case DownloadErrorType.verificationRequired:
return 'Verification required. Open the extension and complete the security check.';
default:
return error ?? 'An error occurred';
}
+1
View File
@@ -58,4 +58,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
DownloadErrorType.verificationRequired: 'verificationRequired',
};
+25 -3
View File
@@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
export 'package:spotiflac_android/services/history_database.dart'
show HistoryLookupRequest, HistoryBatchLookupRequest;
@@ -4902,7 +4903,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'totalDiscs': track.totalDiscs!.toString(),
if (track.isrc != null) 'isrc': track.isrc!,
if (label != null && label.isNotEmpty) 'label': label,
if (copyright != null && copyright.isNotEmpty) 'copyright': copyright,
if (copyright != null && copyright.isNotEmpty)
'copyright': copyright,
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
};
final ac4Result = await PlatformBridge.writeAC4Metadata(
@@ -6636,6 +6638,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadErrorType.network;
case 'permission':
return DownloadErrorType.permission;
case 'verification_required':
return DownloadErrorType.verificationRequired;
default:
return DownloadErrorType.unknown;
}
@@ -6643,6 +6647,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (isExtensionVerificationRequired(errorMsg)) {
return DownloadErrorType.verificationRequired;
}
if (errorMsg.contains('429') ||
lowerMsg.contains('rate limit') ||
lowerMsg.contains('too many requests')) {
@@ -7609,7 +7616,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath);
await PlatformBridge.ensureAC4Config(
decryptedTempPath,
tempPath,
);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
@@ -7688,7 +7698,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source before discarding it. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(decryptedPath, encryptedSource);
await PlatformBridge.ensureAC4Config(
decryptedPath,
encryptedSource,
);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
@@ -8862,6 +8875,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'permission':
errorType = DownloadErrorType.permission;
break;
case 'verification_required':
errorType = DownloadErrorType.verificationRequired;
break;
default:
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
@@ -8873,6 +8889,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
error: errorMsg,
errorType: errorType,
);
if (errorType == DownloadErrorType.verificationRequired) {
unawaited(openPendingExtensionVerification(item.service));
}
_failedInSession++;
try {
@@ -8927,6 +8946,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
error: errorMsg,
errorType: errorType,
);
if (errorType == DownloadErrorType.verificationRequired) {
unawaited(openPendingExtensionVerification(item.service));
}
_failedInSession++;
try {
+88 -5
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -195,9 +198,20 @@ class SearchPlaylist {
class TrackNotifier extends Notifier<TrackState> {
int _currentRequestId = 0;
StreamSubscription<ExtensionSessionGrantEvent>? _sessionGrantSub;
_PendingVerificationSearch? _pendingVerificationSearch;
bool _retryingPendingVerificationSearch = false;
@override
TrackState build() {
_sessionGrantSub ??= PlatformBridge.extensionSessionGrantEvents().listen(
_handleExtensionSessionGrantCompleted,
);
ref.onDispose(() {
_sessionGrantSub?.cancel();
_sessionGrantSub = null;
_pendingVerificationSearch = null;
});
return const TrackState();
}
@@ -314,7 +328,8 @@ class TrackNotifier extends Notifier<TrackState> {
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
@@ -359,10 +374,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(
String query, {
String? filterOverride,
}) async {
Future<void> search(String query, {String? filterOverride}) async {
final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
@@ -601,6 +613,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.i(
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
);
_clearPendingVerificationSearch(extensionId, query, currentFilter);
state = TrackState(
tracks: tracks,
@@ -614,6 +627,18 @@ class TrackNotifier extends Notifier<TrackState> {
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Custom search failed: $e', e, stackTrace);
if (isExtensionVerificationRequired(e)) {
_pendingVerificationSearch = _PendingVerificationSearch(
extensionId: extensionId,
query: query,
options: Map<String, dynamic>.from(
options ?? const <String, dynamic>{},
),
selectedFilter: currentFilter,
createdAt: DateTime.now(),
);
await openPendingExtensionVerification(extensionId);
}
state = TrackState(
isLoading: false,
error: e.toString(),
@@ -624,6 +649,49 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
void _clearPendingVerificationSearch(
String extensionId,
String query,
String? selectedFilter,
) {
final pending = _pendingVerificationSearch;
if (pending == null) return;
if (pending.extensionId == extensionId &&
pending.query == query &&
pending.selectedFilter == selectedFilter) {
_pendingVerificationSearch = null;
}
}
void _handleExtensionSessionGrantCompleted(ExtensionSessionGrantEvent event) {
if (!event.success || _retryingPendingVerificationSearch) return;
final pending = _pendingVerificationSearch;
if (pending == null || pending.extensionId != event.extensionId) return;
if (DateTime.now().difference(pending.createdAt) >
const Duration(minutes: 10)) {
_pendingVerificationSearch = null;
return;
}
_pendingVerificationSearch = null;
_retryingPendingVerificationSearch = true;
Future<void>.delayed(const Duration(milliseconds: 300), () async {
try {
_log.i(
'Retrying custom search after verification: extension=${pending.extensionId}',
);
await customSearch(
pending.extensionId,
pending.query,
options: pending.options,
selectedFilter: pending.selectedFilter,
);
} finally {
_retryingPendingVerificationSearch = false;
}
});
}
Future<void> checkAvailability(int index) async {
if (index < 0 || index >= state.tracks.length) return;
@@ -826,7 +894,22 @@ class TrackNotifier extends Notifier<TrackState> {
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
}
class _PendingVerificationSearch {
final String extensionId;
final String query;
final Map<String, dynamic> options;
final String? selectedFilter;
final DateTime createdAt;
const _PendingVerificationSearch({
required this.extensionId,
required this.query,
required this.options,
required this.selectedFilter,
required this.createdAt,
});
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+5 -3
View File
@@ -293,9 +293,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
.map((file) => file.path)
.whereType<String>()
.toList();
final extensionPaths = selectedPaths
.where((path) => path.toLowerCase().endsWith('.spotiflac-ext'))
.toList();
final extensionPaths = selectedPaths.where((path) {
final lowerPath = path.toLowerCase();
return lowerPath.endsWith('.spotiflac-ext') ||
lowerPath.endsWith('.sflx');
}).toList();
if (extensionPaths.length != selectedPaths.length) {
if (mounted) {
+44
View File
@@ -12,6 +12,16 @@ final _log = AppLogger('PlatformBridge');
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
class ExtensionSessionGrantEvent {
final String extensionId;
final bool success;
const ExtensionSessionGrantEvent({
required this.extensionId,
required this.success,
});
}
class _BridgeCacheEntry {
final Map<String, dynamic> value;
final DateTime expiresAt;
@@ -76,12 +86,46 @@ class PlatformBridge {
static Future<void>? _persistentLookupCacheLoadFuture;
static int _lookupCacheGeneration = 0;
static int _extensionRequestSequence = 0;
static final StreamController<ExtensionSessionGrantEvent>
_extensionSessionGrantEvents =
StreamController<ExtensionSessionGrantEvent>.broadcast();
static bool _backendEventHandlerInstalled = false;
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
static bool get supportsExtensionSystem =>
Platform.isAndroid || Platform.isIOS;
static Stream<ExtensionSessionGrantEvent> extensionSessionGrantEvents() {
_ensureBackendEventHandler();
return _extensionSessionGrantEvents.stream;
}
static void _ensureBackendEventHandler() {
if (_backendEventHandlerInstalled) return;
_backendEventHandlerInstalled = true;
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'extensionSessionGrantCompleted':
final args = call.arguments;
if (args is Map) {
final extensionId = args['extension_id']?.toString().trim() ?? '';
if (extensionId.isNotEmpty) {
_extensionSessionGrantEvents.add(
ExtensionSessionGrantEvent(
extensionId: extensionId,
success: args['success'] != false,
),
);
}
}
return null;
default:
return null;
}
});
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
+42
View File
@@ -0,0 +1,42 @@
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:url_launcher/url_launcher.dart';
final _log = AppLogger('ExtensionAuthLauncher');
bool isExtensionVerificationRequired(Object error) {
final message = error.toString().toLowerCase();
return message.contains('verify_required') ||
message.contains('verification_required') ||
message.contains('needsverification') ||
message.contains('needs verification');
}
Future<void> openPendingExtensionVerification(String extensionId) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return;
try {
final pending = await PlatformBridge.getExtensionPendingAuth(
normalizedExtensionId,
);
final authUrl = pending?['auth_url']?.toString().trim() ?? '';
if (authUrl.isEmpty) return;
final uri = Uri.tryParse(authUrl);
if (uri == null) return;
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (launched) {
_log.i('Opened verification challenge for $normalizedExtensionId');
} else {
_log.w(
'Could not open verification challenge for $normalizedExtensionId',
);
}
} catch (e) {
_log.w(
'Failed to open verification challenge for $normalizedExtensionId: $e',
);
}
}