From b4871540654cad3c5e31e87a01abe89d3d04562c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:16:21 +0700 Subject: [PATCH] fix(backup): restore download and metadata provider order Backups now carry both provider priority lists in the extensions section. Restore applies them after reinstalled extensions are enabled, so the saved order replaces the defaults instead of being reset. --- lib/providers/extension_provider.dart | 14 +- .../extension_provider_priority.dart | 25 +++ test/extension_priority_backup_test.dart | 142 ++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 test/extension_priority_backup_test.dart 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); + }); +}