diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 64f263ea..c1a2e22d 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -618,8 +618,9 @@ class ExtensionNotifier extends Notifier { 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. + /// Builds the extensions section of a backup: the store registry URL, the + /// installed extensions with their id, version, enabled flag and settings, + /// and the download and metadata provider priority order. /// Secret-flagged settings (tokens, API keys) are only included when /// [includeSecrets] is true. Future> exportBackup({ @@ -674,7 +675,11 @@ class ExtensionNotifier extends Notifier { }); } - return {'registry_url': registryUrl, 'items': items}; + return { + 'registry_url': registryUrl, + 'items': items, + ...providerPriorityBackup(), + }; } /// Restores extensions from a backup section produced by [exportBackup]: @@ -782,6 +787,9 @@ class ExtensionNotifier extends Notifier { } await refreshExtensions(); + // Reinstalled and re-enabled extensions now exist, so the saved order can + // be applied in full instead of defaults winning. + await restoreProviderPriorityBackup(data); return ExtensionRestoreResult( installed: installedCount, diff --git a/lib/providers/extension_provider_priority.dart b/lib/providers/extension_provider_priority.dart index c91d8d5b..9bc03303 100644 --- a/lib/providers/extension_provider_priority.dart +++ b/lib/providers/extension_provider_priority.dart @@ -339,6 +339,31 @@ extension ExtensionNotifierProviderPriority on ExtensionNotifier { } } + /// Priority lists for the extensions section of a backup. Empty lists are + /// omitted so restoring them never replaces a device's own order. + Map providerPriorityBackup() => { + if (state.providerPriority.isNotEmpty) + _providerPriorityKey: List.from(state.providerPriority), + if (state.metadataProviderPriority.isNotEmpty) + _metadataProviderPriorityKey: List.from( + state.metadataProviderPriority, + ), + }; + + /// Applies backed-up priority lists. Call after restored extensions are + /// installed: the setters drop IDs that are not available yet, and later + /// installs would otherwise be appended after the defaults. + Future restoreProviderPriorityBackup(Map data) async { + final download = _backupProviderIds(data[_providerPriorityKey]); + if (download.isNotEmpty) await setProviderPriority(download); + final metadata = _backupProviderIds(data[_metadataProviderPriorityKey]); + if (metadata.isNotEmpty) await setMetadataProviderPriority(metadata); + } + + List _backupProviderIds(Object? raw) => raw is List + ? _distinctProviderIds(raw.whereType()) + : const []; + Future cleanup() async { if (_cleanupInFlight) return; _cleanupInFlight = true; diff --git a/test/extension_priority_backup_test.dart b/test/extension_priority_backup_test.dart new file mode 100644 index 00000000..6aacf562 --- /dev/null +++ b/test/extension_priority_backup_test.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +Extension _extension(String id) => Extension.fromJson({ + 'id': id, + 'name': id, + 'display_name': id, + 'version': '1.0.0', + 'enabled': true, + 'has_download_provider': true, + 'has_metadata_provider': true, +}); + +class _InstalledExtensions extends ExtensionNotifier { + @override + ExtensionState build() => ExtensionState( + extensions: [ + _extension('example-a'), + _extension('example-b'), + _extension('example-c'), + ], + providerPriority: const ['example-a', 'example-b', 'example-c'], + metadataProviderPriority: const ['example-a', 'example-b', 'example-c'], + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Map> pushed; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + pushed = {}; + messenger.setMockMethodCallHandler(backend, (call) async { + final args = call.arguments; + if (args is Map && args['priority'] is String) { + pushed[call.method] = (jsonDecode(args['priority'] as String) as List) + .cast(); + } + return null; + }); + }); + tearDown(() => messenger.setMockMethodCallHandler(backend, null)); + + ExtensionNotifier notifierFor(ProviderContainer container) => + container.read(extensionProvider.notifier); + + ProviderContainer container() { + final container = ProviderContainer( + overrides: [extensionProvider.overrideWith(_InstalledExtensions.new)], + ); + addTearDown(container.dispose); + return container; + } + + test('backup carries both provider priority lists', () { + final notifier = notifierFor(container()); + expect(notifier.providerPriorityBackup(), { + 'provider_priority': ['example-a', 'example-b', 'example-c'], + 'metadata_provider_priority': ['example-a', 'example-b', 'example-c'], + }); + }); + + test('restore applies the saved order to state, prefs and backend', () async { + final notifier = notifierFor(container()); + + await notifier.restoreProviderPriorityBackup({ + 'provider_priority': ['example-c', 'example-a', 'example-b'], + 'metadata_provider_priority': ['example-b', 'example-c', 'example-a'], + }); + + expect(notifier.state.providerPriority, [ + 'example-c', + 'example-a', + 'example-b', + ]); + expect(notifier.state.metadataProviderPriority, [ + 'example-b', + 'example-c', + 'example-a', + ]); + expect(pushed['setProviderPriority'], notifier.state.providerPriority); + expect( + pushed['setMetadataProviderPriority'], + notifier.state.metadataProviderPriority, + ); + final prefs = await SharedPreferences.getInstance(); + expect( + jsonDecode(prefs.getString('provider_priority')!), + notifier.state.providerPriority, + ); + expect( + jsonDecode(prefs.getString('metadata_provider_priority')!), + notifier.state.metadataProviderPriority, + ); + }); + + test('restore keeps missing and new providers usable', () async { + final notifier = notifierFor(container()); + + await notifier.restoreProviderPriorityBackup({ + 'provider_priority': ['example-missing', 'example-c'], + }); + + // Unavailable IDs are dropped; providers absent from the backup follow. + expect(notifier.state.providerPriority, [ + 'example-c', + 'example-a', + 'example-b', + ]); + expect(notifier.state.metadataProviderPriority, [ + 'example-a', + 'example-b', + 'example-c', + ]); + expect(pushed.keys, ['setProviderPriority']); + }); + + test('backups without priority lists leave the current order', () async { + final notifier = notifierFor(container()); + + await notifier.restoreProviderPriorityBackup({ + 'items': const [], + 'provider_priority': 'invalid', + }); + + expect(notifier.state.providerPriority, [ + 'example-a', + 'example-b', + 'example-c', + ]); + expect(pushed, isEmpty); + }); +}