feat(home): expose extension country settings and isolate regional caches

This commit is contained in:
zarzet committed 2026-10-18 03:11:33 +07:00
1 parent 2455cabced
commit 1a6dc7366e
11 files changed
+606 -21

No files matched your search

+16
View File
@@ -5330,6 +5330,14 @@
"@extensionsHomeFeedProvider": {
"description": "Extensions page - label for home feed provider selector"
},
"extensionsHomeFeedCountry": "Home feed country",
"@extensionsHomeFeedCountry": {
"description": "Country picker for the active Home feed extension"
},
"extensionsHomeFeedCountryDescription": "Choose a country's Home recommendations without a VPN.",
"@extensionsHomeFeedCountryDescription": {
"description": "Help for choosing regional Home recommendations"
},
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
"@extensionsHomeFeedDescription": {
"description": "Extensions page - description for home feed provider picker"
@@ -6985,6 +6993,14 @@
"@regionCountryUS": {
"description": "Country name for SongLink region picker"
},
"regionCountryIT": "Italy",
"@regionCountryIT": {
"description": "Country name for Italy"
},
"regionCountryES": "Spain",
"@regionCountryES": {
"description": "Country name for Spain"
},
"regionCountryGB": "United Kingdom",
"@regionCountryGB": {
"description": "Country name for SongLink region picker"
+4
View File
@@ -5358,6 +5358,8 @@
"@extensionsHomeFeedProvider": {
"description": "Extensions page - label for home feed provider selector"
},
"extensionsHomeFeedCountry": "Negara beranda",
"extensionsHomeFeedCountryDescription": "Pilih rekomendasi beranda suatu negara tanpa VPN.",
"extensionsHomeFeedDescription": "Pilih ekstensi provider feed beranda di layar utama",
"@extensionsHomeFeedDescription": {
"description": "Extensions page - description for home feed provider picker"
@@ -7025,6 +7027,8 @@
"@regionCountryUS": {
"description": "Country name for SongLink region picker"
},
"regionCountryIT": "Italia",
"regionCountryES": "Spanyol",
"regionCountryGB": "Inggris Raya",
"@regionCountryGB": {
"description": "Country name for SongLink region picker"
+20
View File
@@ -244,6 +244,22 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasServiceHealth => serviceHealth.isNotEmpty;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
/// The extension owns its country choices and the setting sent to its API.
ExtensionSetting? get homeFeedRegionSetting {
if (!hasHomeFeed) return null;
final key = capabilities['homeFeedRegionSetting'];
if (key is! String || key.isEmpty) return null;
return settings.where((setting) {
final options = setting.options;
return setting.key == key &&
(setting.type == 'select' || setting.type == 'string') &&
options != null &&
options.isNotEmpty &&
options.every((code) => RegExp(r'^[a-zA-Z]{2}$').hasMatch(code));
}).firstOrNull;
}
ExtensionDownloadTransferPolicy get downloadTransferPolicy =>
ExtensionDownloadTransferPolicy.fromCapabilities(capabilities);
bool get requiresNativeContainerConversion =>
@@ -760,6 +776,7 @@ class ExtensionState {
final bool isLoading;
final String? error;
final bool isInitialized;
final Map<String, int> settingsRevisions;
const ExtensionState({
this.extensions = const [],
@@ -769,6 +786,7 @@ class ExtensionState {
this.isLoading = false,
this.error,
this.isInitialized = false,
this.settingsRevisions = const {},
});
ExtensionState copyWith({
@@ -779,6 +797,7 @@ class ExtensionState {
bool? isLoading,
String? error,
bool? isInitialized,
Map<String, int>? settingsRevisions,
}) {
return ExtensionState(
extensions: extensions ?? this.extensions,
@@ -789,6 +808,7 @@ class ExtensionState {
isLoading: isLoading ?? this.isLoading,
error: error,
isInitialized: isInitialized ?? this.isInitialized,
settingsRevisions: settingsRevisions ?? this.settingsRevisions,
);
}
}
+80 -16
View File
@@ -12,6 +12,21 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider');
final homeFeedExtensionProvider = Provider<Extension?>((ref) {
final preferred = ref.watch(
settingsProvider.select((settings) => settings.homeFeedProvider),
);
if (preferred == AppSettings.homeFeedProviderOff) return null;
final extensions = ref.watch(
extensionProvider.select((state) => state.extensions),
);
final available = extensions.where((ext) => ext.enabled && ext.hasHomeFeed);
if (preferred != null && preferred.isNotEmpty) {
return available.where((ext) => ext.id == preferred).firstOrNull;
}
return available.firstOrNull;
});
class ExploreItem {
final String id;
final String uri;
@@ -256,7 +271,11 @@ Map<String, Object?> _decodeExploreCache(String rawCache) {
sections = _withDefaultExploreProviderId(sections, providerId);
}
return {'provider_id': providerId, 'sections': sections};
return {
'provider_id': providerId,
'region': decoded['region'],
'sections': sections,
};
}
String _encodeExploreCache(Map<String, Object?> cachePayload) {
@@ -295,6 +314,15 @@ class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
ref.listen(extensionProvider.select((state) => state.settingsRevisions), (
previous,
next,
) {
final extension = _resolveHomeFeedExtension();
if (extension != null && previous?[extension.id] != next[extension.id]) {
_reloadConfiguration(extension.id);
}
});
_restoreFromCache();
return const ExploreState();
}
@@ -328,6 +356,21 @@ class ExploreNotifier extends Notifier<ExploreState> {
final resolvedProviderId = providerId?.isNotEmpty == true
? providerId
: _resolveHomeFeedExtension()?.id;
var extension = _resolveHomeFeedExtension();
if (extension == null && cachePayload['region'] != null) {
// Regional caches cannot be validated until installed manifests and
// their saved settings are available on a cold launch.
await ref.read(extensionProvider.notifier).ensureInitialized();
if (!_isCacheRestoreValid(generation)) return;
extension = _resolveHomeFeedExtension();
if (extension == null) return;
}
if (extension != null) {
if (resolvedProviderId != extension.id) return;
final region = await _homeFeedRegion(extension);
if (!_isCacheRestoreValid(generation)) return;
if (cachePayload['region'] != region) return;
}
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
normalizedSections = _withDefaultExploreProviderId(
normalizedSections,
@@ -369,34 +412,53 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
Extension? _resolveHomeFeedExtension() {
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
final enabledHomeFeedExtensions = ref
.read(extensionProvider)
.extensions
.where((extension) => extension.enabled && extension.hasHomeFeed)
.toList(growable: false);
return ref.read(homeFeedExtensionProvider);
}
if (preferredId != null && preferredId.isNotEmpty) {
return enabledHomeFeedExtensions
.where((extension) => extension.id == preferredId)
.firstOrNull;
Future<String?> _homeFeedRegion(Extension extension) async {
final setting = extension.homeFeedRegionSetting;
if (setting == null) return null;
final settings = await ref.read(
extensionSettingsProvider(extension.id).future,
);
return (settings[setting.key] ?? setting.defaultValue)
?.toString()
.trim()
.toUpperCase();
}
Future<void> _reloadConfiguration(String extensionId) async {
clear();
final generation = _homeFeedRequestId;
try {
final prefs = await SharedPreferences.getInstance();
if (!_isRequestValid(generation)) return;
await ExploreCacheStore(prefs).clear();
} catch (error) {
_log.w('Failed to clear changed home feed: $error');
}
if (_isRequestValid(generation) &&
_resolveHomeFeedExtension()?.id == extensionId) {
await refresh();
}
return enabledHomeFeedExtensions.firstOrNull;
}
Future<void> _saveToCache(
List<Map<String, Object?>> normalizedSections,
String providerId,
String? region,
int requestId,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = await compute(_encodeExploreCache, {
'provider_id': providerId,
'region': region,
'sections': normalizedSections,
});
await ExploreCacheStore(prefs).write(encoded);
if (_isRequestValid(requestId)) {
await ExploreCacheStore(prefs).write(encoded);
}
} catch (e) {
_log.w('Failed to save explore cache: $e');
}
@@ -440,6 +502,8 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
final region = await _homeFeedRegion(targetExt);
if (!_isRequestValid(requestId)) return;
final result = await PlatformBridge.getExtensionHomeFeed(
targetExt.id,
cancelPrevious: forceRefresh,
@@ -490,7 +554,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(),
);
_saveToCache(normalizedSections, targetExt.id);
_saveToCache(normalizedSections, targetExt.id, region, requestId);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
if (!_isRequestValid(requestId)) return;
+6 -1
View File
@@ -22,6 +22,11 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _storeRegistryUrlPrefKey = 'store_registry_url';
final extensionSettingsProvider = FutureProvider.autoDispose
.family<Map<String, dynamic>, String>((ref, extensionId) {
return PlatformBridge.getExtensionSettings(extensionId);
});
/// Result of restoring extensions from a backup.
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
@@ -784,7 +789,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
...current,
...Map<String, dynamic>.from(settings),
};
await PlatformBridge.setExtensionSettings(id, merged);
await setExtensionSettings(id, merged);
} catch (e) {
_log.w('Restore: failed to apply settings for $id: $e');
}
+32 -1
View File
@@ -236,16 +236,47 @@ extension ExtensionNotifierProviderPriority on ExtensionNotifier {
}
}
Future<void> setExtensionSettings(
Future<bool> setExtensionSettings(
String extensionId,
Map<String, dynamic> settings,
) async {
try {
await PlatformBridge.setExtensionSettings(extensionId, settings);
ref.invalidate(extensionSettingsProvider(extensionId));
state = state.copyWith(
settingsRevisions: {
...state.settingsRevisions,
extensionId: (state.settingsRevisions[extensionId] ?? 0) + 1,
},
);
_log.d('Updated settings for extension: $extensionId');
return true;
} catch (e) {
_log.e('Failed to set extension settings: $e');
state = state.copyWith(error: e.toString());
return false;
}
}
Future<bool> setHomeFeedRegion(String extensionId, String region) async {
final extension = state.extensions
.where((extension) => extension.id == extensionId)
.firstOrNull;
final setting = extension?.homeFeedRegionSetting;
if (setting == null || !setting.options!.contains(region)) return false;
try {
final settings = await PlatformBridge.getExtensionSettings(extensionId);
if ((settings[setting.key] ?? setting.defaultValue) == region) {
return true;
}
return await setExtensionSettings(extensionId, {
...settings,
setting.key: region,
});
} catch (error) {
_log.e('Failed to update home feed country: $error');
state = state.copyWith(error: error.toString());
return false;
}
}
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/providers/repo_provider.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/extension_action_forms.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/utils/extension_country_name.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
@@ -268,6 +269,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final setting = entry.value;
return _SettingItem(
key: ValueKey(setting.key),
countryOptions:
setting.key == extension.homeFeedRegionSetting?.key,
setting: setting,
value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1,
@@ -1026,6 +1029,7 @@ class _SettingItem extends StatefulWidget {
final bool showDivider;
final ValueChanged<dynamic> onChanged;
final String extensionId;
final bool countryOptions;
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
const _SettingItem({
@@ -1034,6 +1038,7 @@ class _SettingItem extends StatefulWidget {
required this.value,
required this.onChanged,
required this.extensionId,
this.countryOptions = false,
this.onActionPayload,
this.showDivider = true,
});
@@ -1065,7 +1070,9 @@ class _SettingItemState extends State<_SettingItem> {
? _showOptions
: null;
footer = Text(
widget.value?.toString() ?? context.l10n.extensionSettingNotSet,
widget.countryOptions
? extensionCountryName(context, widget.value?.toString() ?? '')
: widget.value?.toString() ?? context.l10n.extensionSettingNotSet,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.primary),
@@ -1090,7 +1097,13 @@ class _SettingItemState extends State<_SettingItem> {
colorScheme: colorScheme,
)
: Text(
widget.value?.toString() ?? context.l10n.extensionSettingNotSet,
widget.countryOptions
? extensionCountryName(
context,
widget.value?.toString() ?? '',
)
: widget.value?.toString() ??
context.l10n.extensionSettingNotSet,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.primary),
@@ -1120,7 +1133,11 @@ class _SettingItemState extends State<_SettingItem> {
children: [
for (final option in widget.setting.options ?? <String>[])
AppSheetOption.choice(
title: Text(option),
title: Text(
widget.countryOptions
? extensionCountryName(sheetContext, option)
: option,
),
selected: option == widget.value,
onTap: () => Navigator.pop(sheetContext, option),
),
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/home_feed_region_setting.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class ExtensionsPage extends ConsumerStatefulWidget {
@@ -125,6 +126,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
_MetadataPriorityItem(),
_SearchProviderSelector(),
_HomeFeedProviderSelector(),
HomeFeedRegionSetting(),
],
),
),
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
String extensionCountryName(BuildContext context, String code) {
final l10n = context.l10n;
return switch (code.toUpperCase()) {
'US' => l10n.regionCountryUS,
'GB' => l10n.regionCountryGB,
'FR' => l10n.regionCountryFR,
'DE' => l10n.regionCountryDE,
'IT' => l10n.regionCountryIT,
'ES' => l10n.regionCountryES,
'JP' => l10n.regionCountryJP,
'KR' => l10n.regionCountryKR,
'IN' => l10n.regionCountryIN,
'ID' => l10n.regionCountryID,
'BR' => l10n.regionCountryBR,
'MX' => l10n.regionCountryMX,
'AU' => l10n.regionCountryAU,
'CA' => l10n.regionCountryCA,
'XK' => l10n.regionCountryXK,
_ => code.toUpperCase(),
};
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/extension_country_name.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class HomeFeedRegionSetting extends ConsumerStatefulWidget {
const HomeFeedRegionSetting({super.key});
@override
ConsumerState<HomeFeedRegionSetting> createState() =>
_HomeFeedRegionSettingState();
}
class _HomeFeedRegionSettingState extends ConsumerState<HomeFeedRegionSetting> {
bool _saving = false;
@override
Widget build(BuildContext context) {
final extension = ref.watch(homeFeedExtensionProvider);
final setting = extension?.homeFeedRegionSetting;
if (extension == null || setting == null) return const SizedBox.shrink();
final saved = ref.watch(extensionSettingsProvider(extension.id));
final selected = (saved.value?[setting.key] ?? setting.defaultValue)
?.toString();
return SettingsItem(
icon: Icons.public,
title: context.l10n.extensionsHomeFeedCountry,
subtitle: saved.hasError
? context.friendlyError(saved.error)
: '${extensionCountryName(context, selected ?? '')} · ${extension.displayName}',
showDivider: false,
onTap: _saving
? null
: () async {
if (saved.hasError) {
ref.invalidate(extensionSettingsProvider(extension.id));
return;
}
final region = await showAppScrollableBottomSheet<String>(
context: context,
title: context.l10n.extensionsHomeFeedCountry,
subtitle:
setting.description ??
context.l10n.extensionsHomeFeedCountryDescription,
builder: (sheetContext, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final code in setting.options!)
AppSheetOption.choice(
title: Text(extensionCountryName(sheetContext, code)),
subtitle: Text(code.toUpperCase()),
selected:
code.toUpperCase() == selected?.toUpperCase(),
onTap: () => Navigator.pop(sheetContext, code),
),
],
),
),
);
if (!mounted || region == null) return;
setState(() => _saving = true);
final success = await ref
.read(extensionProvider.notifier)
.setHomeFeedRegion(extension.id, region);
if (!mounted || !context.mounted) return;
setState(() => _saving = false);
if (!success) {
showAppSnackBar(
context,
content: Text(
context.l10n.snackbarError(
context.friendlyError(ref.read(extensionProvider).error),
),
),
);
}
},
);
}
}