mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
658 lines
20 KiB
Dart
658 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
|
|
part 'extension_models.dart';
|
|
part 'extension_provider_priority.dart';
|
|
|
|
final _log = AppLogger('ExtensionProvider');
|
|
|
|
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
|
const _providerPriorityKey = 'provider_priority';
|
|
const _storeRegistryUrlPrefKey = 'store_registry_url';
|
|
|
|
/// Result of restoring extensions from a backup.
|
|
class ExtensionNotifier extends Notifier<ExtensionState> {
|
|
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
|
|
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
|
|
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
|
|
AppLifecycleListener? _appLifecycleListener;
|
|
bool _cleanupInFlight = false;
|
|
Completer<void>? _initializationCompleter;
|
|
final Map<String, DateTime> _healthExpiresAt = {};
|
|
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
|
|
final Map<String, int> _healthRequestSerial = {};
|
|
|
|
@override
|
|
ExtensionState build() {
|
|
_appLifecycleListener ??= AppLifecycleListener(
|
|
onDetach: _scheduleLifecycleCleanup,
|
|
);
|
|
ref.onDispose(() {
|
|
_appLifecycleListener?.dispose();
|
|
_appLifecycleListener = null;
|
|
_healthExpiresAt.clear();
|
|
_healthInFlight.clear();
|
|
_healthRequestSerial.clear();
|
|
});
|
|
return const ExtensionState();
|
|
}
|
|
|
|
void _scheduleLifecycleCleanup() {
|
|
if (_cleanupInFlight) return;
|
|
_cleanupInFlight = true;
|
|
unawaited(_cleanupExtensions(reason: 'lifecycle detach'));
|
|
}
|
|
|
|
Future<void> _cleanupExtensions({required String reason}) async {
|
|
if (!PlatformBridge.supportsExtensionSystem) {
|
|
_cleanupInFlight = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await PlatformBridge.cleanupExtensions();
|
|
_log.d('Extensions cleaned up ($reason)');
|
|
} catch (e) {
|
|
_log.w('Extension cleanup failed ($reason): $e');
|
|
} finally {
|
|
_cleanupInFlight = false;
|
|
}
|
|
}
|
|
|
|
Future<void> initialize(String extensionsDir, String dataDir) async {
|
|
if (state.isInitialized) return;
|
|
if (_initializationCompleter != null) {
|
|
await _initializationCompleter!.future;
|
|
return;
|
|
}
|
|
|
|
final completer = Completer<void>();
|
|
_initializationCompleter = completer;
|
|
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
if (!PlatformBridge.supportsExtensionSystem) {
|
|
state = state.copyWith(
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
extensions: const [],
|
|
error: null,
|
|
);
|
|
_log.i('Extension system disabled on this platform');
|
|
completer.complete();
|
|
_initializationCompleter = null;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
|
await loadExtensions(extensionsDir);
|
|
await loadProviderPriority();
|
|
await loadMetadataProviderPriority();
|
|
state = state.copyWith(isInitialized: true, isLoading: false);
|
|
_log.i('Extension system initialized');
|
|
} catch (e) {
|
|
_log.e('Failed to initialize extension system: $e');
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
} finally {
|
|
if (!completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
if (identical(_initializationCompleter, completer)) {
|
|
_initializationCompleter = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> waitForInitialization({
|
|
Duration timeout = const Duration(seconds: 30),
|
|
}) async {
|
|
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
|
|
return;
|
|
}
|
|
|
|
final future = _initializationCompleter?.future;
|
|
if (future == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await future.timeout(timeout);
|
|
} on TimeoutException {
|
|
_log.w('Timed out waiting for extension initialization after $timeout');
|
|
}
|
|
}
|
|
|
|
Future<void> loadExtensions(String dirPath) async {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
try {
|
|
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
|
_log.d('Load extensions result: $result');
|
|
await refreshExtensions();
|
|
state = state.copyWith(isLoading: false);
|
|
} catch (e) {
|
|
_log.e('Failed to load extensions: $e');
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> refreshExtensions() async {
|
|
try {
|
|
final list = await PlatformBridge.getInstalledExtensions();
|
|
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
|
state = state.copyWith(extensions: extensions);
|
|
await _reconcileDownloadProviderPriority();
|
|
await _reconcileDefaultDownloadService();
|
|
await _reconcileMetadataProviderPriority();
|
|
_reconcileSearchProvider();
|
|
_scheduleExtensionHealthRefresh(extensions);
|
|
_log.d('Loaded ${extensions.length} extensions');
|
|
|
|
for (final ext in extensions) {
|
|
if (ext.searchBehavior != null) {
|
|
_log.d(
|
|
'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}',
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.e('Failed to refresh extensions: $e');
|
|
state = state.copyWith(error: e.toString());
|
|
}
|
|
}
|
|
|
|
void _scheduleExtensionHealthRefresh(
|
|
List<Extension> extensions, {
|
|
bool force = false,
|
|
}) {
|
|
for (final ext in extensions) {
|
|
if (!ext.enabled || !ext.hasServiceHealth) continue;
|
|
unawaited(checkExtensionHealth(ext.id, force: force));
|
|
}
|
|
}
|
|
|
|
void refreshEnabledExtensionHealth({bool force = false}) {
|
|
_scheduleExtensionHealthRefresh(state.extensions, force: force);
|
|
}
|
|
|
|
Duration _extensionHealthCacheTtlFor(Extension extension) {
|
|
var ttl = _extensionHealthDefaultCacheTtl;
|
|
for (final check in extension.serviceHealth) {
|
|
final seconds = check.cacheTtlSeconds;
|
|
if (seconds == null || seconds <= 0) continue;
|
|
|
|
var checkTtl = Duration(seconds: seconds);
|
|
if (checkTtl < _extensionHealthMinimumCacheTtl) {
|
|
checkTtl = _extensionHealthMinimumCacheTtl;
|
|
}
|
|
if (checkTtl < ttl) {
|
|
ttl = checkTtl;
|
|
}
|
|
}
|
|
return ttl;
|
|
}
|
|
|
|
Duration _extensionHealthCacheTtlForStatus(
|
|
Extension extension,
|
|
String status,
|
|
) {
|
|
final ttl = _extensionHealthCacheTtlFor(extension);
|
|
if (status == 'unknown' && ttl > _extensionHealthUnknownCacheTtl) {
|
|
return _extensionHealthUnknownCacheTtl;
|
|
}
|
|
return ttl;
|
|
}
|
|
|
|
Future<ExtensionHealthStatus?> checkExtensionHealth(
|
|
String extensionId, {
|
|
bool force = false,
|
|
}) async {
|
|
final ext = state.extensions
|
|
.where((extension) => extension.id == extensionId)
|
|
.firstOrNull;
|
|
if (ext == null || !ext.hasServiceHealth) {
|
|
return null;
|
|
}
|
|
|
|
final expiresAt = _healthExpiresAt[extensionId];
|
|
final cached = state.healthStatuses[extensionId];
|
|
if (!force &&
|
|
cached != null &&
|
|
expiresAt != null &&
|
|
DateTime.now().isBefore(expiresAt)) {
|
|
return cached;
|
|
}
|
|
|
|
final inFlight = _healthInFlight[extensionId];
|
|
if (!force && inFlight != null) {
|
|
return inFlight;
|
|
}
|
|
|
|
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
|
|
_healthRequestSerial[extensionId] = requestSerial;
|
|
|
|
final future = () async {
|
|
try {
|
|
final result = await PlatformBridge.checkExtensionHealth(extensionId);
|
|
final status = ExtensionHealthStatus.fromJson(result);
|
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
|
final updated = Map<String, ExtensionHealthStatus>.of(
|
|
state.healthStatuses,
|
|
)..[extensionId] = status;
|
|
_healthExpiresAt[extensionId] = DateTime.now().add(
|
|
_extensionHealthCacheTtlForStatus(ext, status.status),
|
|
);
|
|
state = state.copyWith(healthStatuses: updated);
|
|
}
|
|
return status;
|
|
} catch (e) {
|
|
_log.w('Failed to check extension health for $extensionId: $e');
|
|
final status = ExtensionHealthStatus(
|
|
extensionId: extensionId,
|
|
status: 'unknown',
|
|
checkedAt: DateTime.now(),
|
|
checks: const [],
|
|
);
|
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
|
final updated = Map<String, ExtensionHealthStatus>.of(
|
|
state.healthStatuses,
|
|
)..[extensionId] = status;
|
|
_healthExpiresAt[extensionId] = DateTime.now().add(
|
|
_extensionHealthUnknownCacheTtl,
|
|
);
|
|
state = state.copyWith(healthStatuses: updated);
|
|
}
|
|
return status;
|
|
} finally {
|
|
if (_healthRequestSerial[extensionId] == requestSerial) {
|
|
_healthInFlight.remove(extensionId);
|
|
}
|
|
}
|
|
}();
|
|
|
|
_healthInFlight[extensionId] = future;
|
|
return future;
|
|
}
|
|
|
|
void clearError() {
|
|
state = state.copyWith(error: null);
|
|
}
|
|
|
|
Future<bool> installExtension(String filePath) async {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
try {
|
|
final result = await PlatformBridge.loadExtensionFromPath(filePath);
|
|
_log.i('Installed extension: ${result['name']}');
|
|
await refreshExtensions();
|
|
state = state.copyWith(isLoading: false);
|
|
return true;
|
|
} catch (e) {
|
|
_log.e('Failed to install extension: $e');
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<ExtensionInstallBatchResult> installExtensions(
|
|
List<String> filePaths,
|
|
) async {
|
|
final uniquePaths = <String>[];
|
|
for (final path in filePaths) {
|
|
final trimmed = path.trim();
|
|
if (trimmed.isEmpty || uniquePaths.contains(trimmed)) continue;
|
|
uniquePaths.add(trimmed);
|
|
}
|
|
|
|
if (uniquePaths.isEmpty) {
|
|
return const ExtensionInstallBatchResult(attempted: 0, installed: 0);
|
|
}
|
|
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
var installed = 0;
|
|
final failures = <String, String>{};
|
|
|
|
for (final path in uniquePaths) {
|
|
try {
|
|
final result = await PlatformBridge.loadExtensionFromPath(path);
|
|
installed++;
|
|
_log.i('Installed extension: ${result['name']}');
|
|
} catch (e) {
|
|
_log.e('Failed to install extension from $path: $e');
|
|
failures[path] = e.toString();
|
|
}
|
|
}
|
|
|
|
if (installed > 0) {
|
|
await refreshExtensions();
|
|
}
|
|
|
|
final firstError = failures.values.firstOrNull;
|
|
state = state.copyWith(isLoading: false, error: firstError);
|
|
|
|
return ExtensionInstallBatchResult(
|
|
attempted: uniquePaths.length,
|
|
installed: installed,
|
|
failures: failures,
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
|
try {
|
|
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
|
} catch (e) {
|
|
_log.e('Failed to check extension upgrade: $e');
|
|
return {'error': e.toString()};
|
|
}
|
|
}
|
|
|
|
Future<bool> upgradeExtension(String filePath) async {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
try {
|
|
final result = await PlatformBridge.upgradeExtension(filePath);
|
|
_log.i(
|
|
'Upgraded extension: ${result['display_name']} to v${result['version']}',
|
|
);
|
|
await refreshExtensions();
|
|
state = state.copyWith(isLoading: false);
|
|
return true;
|
|
} catch (e) {
|
|
_log.e('Failed to upgrade extension: $e');
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> removeExtension(String extensionId) async {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
|
|
try {
|
|
await PlatformBridge.removeExtension(extensionId);
|
|
_log.i('Removed extension: $extensionId');
|
|
await refreshExtensions();
|
|
state = state.copyWith(isLoading: false);
|
|
return true;
|
|
} catch (e) {
|
|
_log.e('Failed to remove extension: $e');
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
|
try {
|
|
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
|
_log.d('Set extension $extensionId enabled: $enabled');
|
|
|
|
final ext = state.extensions
|
|
.where((e) => e.id == extensionId)
|
|
.firstOrNull;
|
|
|
|
final extensions = state.extensions.map((e) {
|
|
if (e.id == extensionId) {
|
|
return e.copyWith(enabled: enabled);
|
|
}
|
|
return e;
|
|
}).toList();
|
|
|
|
state = state.copyWith(extensions: extensions);
|
|
await _reconcileDownloadProviderPriority();
|
|
await _reconcileDefaultDownloadService();
|
|
await _reconcileMetadataProviderPriority();
|
|
_reconcileSearchProvider();
|
|
|
|
final updatedExt = extensions
|
|
.where((extension) => extension.id == extensionId)
|
|
.firstOrNull;
|
|
if (enabled && updatedExt?.hasServiceHealth == true) {
|
|
unawaited(checkExtensionHealth(extensionId, force: true));
|
|
}
|
|
|
|
if (!enabled && ext != null) {
|
|
final settings = ref.read(settingsProvider);
|
|
|
|
if (settings.searchProvider == extensionId) {
|
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
|
_log.d(
|
|
'Cleared search provider because extension $extensionId was disabled',
|
|
);
|
|
}
|
|
|
|
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
|
final fallbackService =
|
|
_firstEnabledExtensionDownloadProviderId() ?? '';
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDefaultService(fallbackService);
|
|
_log.d(
|
|
fallbackService.isEmpty
|
|
? 'Cleared default service because extension $extensionId was disabled'
|
|
: 'Reset default service to $fallbackService because extension $extensionId was disabled',
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.e('Failed to set extension enabled: $e');
|
|
state = state.copyWith(error: e.toString());
|
|
}
|
|
}
|
|
|
|
List<Extension> searchProviders() {
|
|
return state.extensions
|
|
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
|
.toList();
|
|
}
|
|
|
|
/// Collects the keys flagged as `secret` in an extension's manifest schema
|
|
/// (top-level settings and quality-specific settings).
|
|
Set<String> _secretKeysFromManifest(Map<String, dynamic> raw) {
|
|
final keys = <String>{};
|
|
|
|
void scan(Object? settingsList) {
|
|
if (settingsList is! List) return;
|
|
for (final entry in settingsList) {
|
|
if (entry is Map && entry['secret'] == true && entry['key'] is String) {
|
|
keys.add(entry['key'] as String);
|
|
}
|
|
}
|
|
}
|
|
|
|
scan(raw['settings']);
|
|
final quality = raw['quality_options'];
|
|
if (quality is List) {
|
|
for (final option in quality) {
|
|
if (option is Map) {
|
|
scan(option['settings']);
|
|
}
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/// Builds the extensions section of a backup: the store registry URL plus the
|
|
/// installed extensions with their id, version, enabled flag and settings.
|
|
/// Secret-flagged settings (tokens, API keys) are only included when
|
|
/// [includeSecrets] is true.
|
|
Future<Map<String, dynamic>> exportBackup({
|
|
required bool includeSecrets,
|
|
}) async {
|
|
if (!PlatformBridge.supportsExtensionSystem) {
|
|
return {'registry_url': '', 'items': const <Map<String, dynamic>>[]};
|
|
}
|
|
|
|
String registryUrl = '';
|
|
try {
|
|
registryUrl = await PlatformBridge.getRepoRegistryUrl();
|
|
} catch (_) {}
|
|
|
|
List<Map<String, dynamic>> installed;
|
|
try {
|
|
installed = await PlatformBridge.getInstalledExtensions();
|
|
} catch (e) {
|
|
_log.w('Backup: failed to list extensions: $e');
|
|
installed = const [];
|
|
}
|
|
|
|
final items = <Map<String, dynamic>>[];
|
|
for (final raw in installed) {
|
|
final id = raw['id'] as String?;
|
|
if (id == null || id.isEmpty) continue;
|
|
final secretKeys = _secretKeysFromManifest(raw);
|
|
|
|
Map<String, dynamic> settings = {};
|
|
try {
|
|
settings = await PlatformBridge.getExtensionSettings(id);
|
|
} catch (_) {}
|
|
|
|
final filtered = <String, dynamic>{};
|
|
var omittedSecret = false;
|
|
settings.forEach((key, value) {
|
|
if (secretKeys.contains(key)) {
|
|
if (!includeSecrets) {
|
|
omittedSecret = true;
|
|
return;
|
|
}
|
|
}
|
|
filtered[key] = value;
|
|
});
|
|
|
|
items.add({
|
|
'id': id,
|
|
'version': raw['version']?.toString() ?? '',
|
|
'enabled': raw['enabled'] == true,
|
|
'settings': filtered,
|
|
if (omittedSecret) 'secrets_omitted': true,
|
|
});
|
|
}
|
|
|
|
return {'registry_url': registryUrl, 'items': items};
|
|
}
|
|
|
|
/// Restores extensions from a backup section produced by [exportBackup]:
|
|
/// re-applies the store registry URL, reinstalls each extension from the
|
|
/// store when missing, then merges settings and restores the enabled flag.
|
|
/// Missing settings (e.g. omitted secrets) are merged with the current values
|
|
/// so they are not wiped.
|
|
Future<ExtensionRestoreResult> restoreFromBackup(
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
if (!PlatformBridge.supportsExtensionSystem) {
|
|
return const ExtensionRestoreResult();
|
|
}
|
|
|
|
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
|
|
final itemsRaw = data['items'];
|
|
final items = itemsRaw is List
|
|
? itemsRaw
|
|
.whereType<Map<Object?, Object?>>()
|
|
.map((e) => Map<String, dynamic>.from(e))
|
|
.toList()
|
|
: <Map<String, dynamic>>[];
|
|
|
|
Directory? destDir;
|
|
try {
|
|
final tmp = await getTemporaryDirectory();
|
|
destDir = await Directory(
|
|
'${tmp.path}/spotiflac_restore_ext',
|
|
).create(recursive: true);
|
|
await PlatformBridge.initExtensionRepo(destDir.path);
|
|
if (registryUrl.isNotEmpty) {
|
|
await PlatformBridge.setRepoRegistryUrl(registryUrl);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_storeRegistryUrlPrefKey, registryUrl);
|
|
}
|
|
} catch (e) {
|
|
_log.w('Restore: failed to prepare extension store: $e');
|
|
}
|
|
|
|
await refreshExtensions();
|
|
final installedIds = state.extensions
|
|
.map((e) => e.id.toLowerCase())
|
|
.toSet();
|
|
|
|
var installedCount = 0;
|
|
var alreadyPresent = 0;
|
|
var failed = 0;
|
|
final failedIds = <String>[];
|
|
|
|
for (final item in items) {
|
|
final id = item['id'] as String?;
|
|
if (id == null || id.isEmpty) continue;
|
|
final enabled = item['enabled'] != false;
|
|
var present = installedIds.contains(id.toLowerCase());
|
|
|
|
if (!present) {
|
|
if (destDir == null) {
|
|
failed++;
|
|
failedIds.add(id);
|
|
continue;
|
|
}
|
|
try {
|
|
final path = await PlatformBridge.downloadRepoExtension(
|
|
id,
|
|
destDir.path,
|
|
);
|
|
final ok = await installExtension(path);
|
|
if (ok) {
|
|
installedCount++;
|
|
present = true;
|
|
} else {
|
|
failed++;
|
|
failedIds.add(id);
|
|
}
|
|
} catch (e) {
|
|
_log.w('Restore: failed to install extension $id: $e');
|
|
failed++;
|
|
failedIds.add(id);
|
|
}
|
|
} else {
|
|
alreadyPresent++;
|
|
}
|
|
|
|
if (!present) continue;
|
|
|
|
final settings = item['settings'];
|
|
if (settings is Map && settings.isNotEmpty) {
|
|
try {
|
|
final current = await PlatformBridge.getExtensionSettings(id);
|
|
final merged = <String, dynamic>{
|
|
...current,
|
|
...Map<String, dynamic>.from(settings),
|
|
};
|
|
await PlatformBridge.setExtensionSettings(id, merged);
|
|
} catch (e) {
|
|
_log.w('Restore: failed to apply settings for $id: $e');
|
|
}
|
|
}
|
|
|
|
try {
|
|
await setExtensionEnabled(id, enabled);
|
|
} catch (_) {}
|
|
}
|
|
|
|
await refreshExtensions();
|
|
|
|
return ExtensionRestoreResult(
|
|
installed: installedCount,
|
|
alreadyPresent: alreadyPresent,
|
|
failed: failed,
|
|
failedIds: failedIds,
|
|
);
|
|
}
|
|
}
|
|
|
|
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
|
ExtensionNotifier.new,
|
|
);
|