From 1787059f42a9e9f0ecc1da75495a9a189af846db Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 26 Jun 2026 20:11:51 +0700 Subject: [PATCH] feat(backup): include installed extensions and settings in backup/restore Back up the store registry URL plus each installed extension (id, version, enabled flag and settings) and restore them on a new device by reinstalling from the store and re-applying settings. Secret-flagged settings (tokens/API keys) are excluded by default behind an opt-in 'Include extension credentials' toggle. Device-bound signed sessions are never backed up. Settings are merged on restore so omitted secrets are not wiped; failed reinstalls are reported. --- lib/l10n/app_localizations.dart | 42 ++++ lib/l10n/app_localizations_ar.dart | 40 ++++ lib/l10n/app_localizations_de.dart | 40 ++++ lib/l10n/app_localizations_en.dart | 40 ++++ lib/l10n/app_localizations_es.dart | 40 ++++ lib/l10n/app_localizations_fr.dart | 40 ++++ lib/l10n/app_localizations_hi.dart | 40 ++++ lib/l10n/app_localizations_id.dart | 34 +++ lib/l10n/app_localizations_ja.dart | 40 ++++ lib/l10n/app_localizations_ko.dart | 40 ++++ lib/l10n/app_localizations_nl.dart | 40 ++++ lib/l10n/app_localizations_pt.dart | 40 ++++ lib/l10n/app_localizations_ru.dart | 40 ++++ lib/l10n/app_localizations_tr.dart | 40 ++++ lib/l10n/app_localizations_uk.dart | 40 ++++ lib/l10n/app_localizations_zh.dart | 40 ++++ lib/l10n/arb/app_en.arb | 38 ++++ lib/l10n/arb/app_id.arb | 30 +++ lib/providers/extension_provider.dart | 215 ++++++++++++++++++ lib/screens/settings/backup_restore_page.dart | 53 ++++- lib/services/backup_service.dart | 22 +- 21 files changed, 989 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1bbec51c..32784bcf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5161,6 +5161,30 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 favorite artist} other{{count} favorite artists}}'** String backupContentsArtists(int count); + /// Backup contents row for installed extensions count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 extension} other{{count} extensions}}'** + String backupContentsExtensions(int count); + + /// Toggle to include secret extension settings (tokens, API keys) in the backup + /// + /// In en, this message translates to: + /// **'Include extension credentials'** + String get backupIncludeSecrets; + + /// Explanation for the include-credentials toggle + /// + /// In en, this message translates to: + /// **'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'** + String get backupIncludeSecretsDescription; + + /// Snackbar/hint when some extensions failed to reinstall during restore + /// + /// In en, this message translates to: + /// **'{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.'** + String backupExtensionsRestoreFailed(int count); + /// Tooltip for the Love All button on album/playlist screens /// /// In en, this message translates to: @@ -5373,6 +5397,24 @@ abstract class AppLocalizations { /// **'Using standard network settings'** String get downloadNetworkCompatibilityModeDisabled; + /// Setting title for allowing requests to private/local network targets + /// + /// In en, this message translates to: + /// **'Allow Local Network Access'** + String get downloadAllowLocalNetwork; + + /// Subtitle when allow local network access is on + /// + /// In en, this message translates to: + /// **'Requests to local/private addresses are allowed (for local proxy or custom DNS)'** + String get downloadAllowLocalNetworkEnabled; + + /// Subtitle when allow local network access is off + /// + /// In en, this message translates to: + /// **'Local/private addresses are blocked for security'** + String get downloadAllowLocalNetworkDisabled; + /// Subtitle when quality picker is disabled due to extension service /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 4213d446..a00c7cae 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsAr extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 91ed36b3..9df82d89 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3051,6 +3051,35 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Alle lieben'; @@ -3183,6 +3212,17 @@ class AppLocalizationsDe extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Standard-Netzwerkeinstellungen verwenden'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index be94ed08..b92ccf2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsEn extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0c754567..097be258 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsEs extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 47c32dbf..7325f28d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3091,6 +3091,35 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Tout aimer'; @@ -3228,6 +3257,17 @@ class AppLocalizationsFr extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Utilisation des paramètres réseau par défaut'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index b055c5bd..539b43c2 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsHi extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6e054ff6..78b122d2 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3004,6 +3004,29 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extension', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Sertakan kredensial extension'; + + @override + String get backupIncludeSecretsDescription => + 'Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.'; + + @override + String backupExtensionsRestoreFailed(int count) { + return '$count extension gagal dipasang ulang. Pasang manual dari store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3133,6 +3156,17 @@ class AppLocalizationsId extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Izinkan Akses Jaringan Lokal'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Alamat lokal/privat diblokir demi keamanan'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 631f470b..59adabc1 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3002,6 +3002,35 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3131,6 +3160,17 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a9bcb50e..0c55ab9a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2999,6 +2999,35 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3128,6 +3157,17 @@ class AppLocalizationsKo extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 98b2c6a2..a5b95728 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsNl extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 5fb4580a..cb4d34f1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsPt extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5f2217fd..8ae03908 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3069,6 +3069,35 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3198,6 +3227,17 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 3f246b06..4826f052 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3043,6 +3043,35 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3175,6 +3204,17 @@ class AppLocalizationsTr extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index de8210a4..a4e4b05c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3058,6 +3058,35 @@ class AppLocalizationsUk extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Уподобати всіх'; @@ -3190,6 +3219,17 @@ class AppLocalizationsUk extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 12ef5585..536fbabb 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3014,6 +3014,35 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String backupContentsExtensions(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count extensions', + one: '1 extension', + ); + return '$_temp0'; + } + + @override + String get backupIncludeSecrets => 'Include extension credentials'; + + @override + String get backupIncludeSecretsDescription => + 'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'; + + @override + String backupExtensionsRestoreFailed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'extensions', + one: 'extension', + ); + return '$count $_temp0 could not be reinstalled. Install them manually from the store.'; + } + @override String get tooltipLoveAll => 'Love All'; @@ -3143,6 +3172,17 @@ class AppLocalizationsZh extends AppLocalizations { String get downloadNetworkCompatibilityModeDisabled => 'Using standard network settings'; + @override + String get downloadAllowLocalNetwork => 'Allow Local Network Access'; + + @override + String get downloadAllowLocalNetworkEnabled => + 'Requests to local/private addresses are allowed (for local proxy or custom DNS)'; + + @override + String get downloadAllowLocalNetworkDisabled => + 'Local/private addresses are blocked for security'; + @override String get downloadSelectServiceToEnable => 'Select a provider with quality options to enable this option'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2644f161..aed59a57 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3964,6 +3964,32 @@ } } }, + "backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}", + "@backupContentsExtensions": { + "description": "Backup contents row for installed extensions count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "backupIncludeSecrets": "Include extension credentials", + "@backupIncludeSecrets": { + "description": "Toggle to include secret extension settings (tokens, API keys) in the backup" + }, + "backupIncludeSecretsDescription": "Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.", + "@backupIncludeSecretsDescription": { + "description": "Explanation for the include-credentials toggle" + }, + "backupExtensionsRestoreFailed": "{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.", + "@backupExtensionsRestoreFailed": { + "description": "Snackbar/hint when some extensions failed to reinstall during restore", + "placeholders": { + "count": { + "type": "int" + } + } + }, "tooltipLoveAll": "Love All", "@tooltipLoveAll": { "description": "Tooltip for the Love All button on album/playlist screens" @@ -4120,6 +4146,18 @@ "@downloadNetworkCompatibilityModeDisabled": { "description": "Subtitle when network compatibility mode is off" }, + "downloadAllowLocalNetwork": "Allow Local Network Access", + "@downloadAllowLocalNetwork": { + "description": "Setting title for allowing requests to private/local network targets" + }, + "downloadAllowLocalNetworkEnabled": "Requests to local/private addresses are allowed (for local proxy or custom DNS)", + "@downloadAllowLocalNetworkEnabled": { + "description": "Subtitle when allow local network access is on" + }, + "downloadAllowLocalNetworkDisabled": "Local/private addresses are blocked for security", + "@downloadAllowLocalNetworkDisabled": { + "description": "Subtitle when allow local network access is off" + }, "downloadSelectServiceToEnable": "Select a provider with quality options to enable this option", "@downloadSelectServiceToEnable": { "description": "Subtitle when quality picker is disabled due to extension service" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index f8a04bfa..d99eb718 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3752,6 +3752,24 @@ } } }, + "backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extension}}", + "@backupContentsExtensions": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "backupIncludeSecrets": "Sertakan kredensial extension", + "backupIncludeSecretsDescription": "Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.", + "backupExtensionsRestoreFailed": "{count} extension gagal dipasang ulang. Pasang manual dari store.", + "@backupExtensionsRestoreFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "tooltipLoveAll": "Love All", "@tooltipLoveAll": { "description": "Tooltip for the Love All button on album/playlist screens" @@ -3892,6 +3910,18 @@ "@downloadNetworkCompatibilityModeDisabled": { "description": "Subtitle when network compatibility mode is disabled" }, + "downloadAllowLocalNetwork": "Izinkan Akses Jaringan Lokal", + "@downloadAllowLocalNetwork": { + "description": "Setting title for allowing requests to private/local network targets" + }, + "downloadAllowLocalNetworkEnabled": "Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)", + "@downloadAllowLocalNetworkEnabled": { + "description": "Subtitle when allow local network access is on" + }, + "downloadAllowLocalNetworkDisabled": "Alamat lokal/privat diblokir demi keamanan", + "@downloadAllowLocalNetworkDisabled": { + "description": "Subtitle when allow local network access is off" + }, "downloadSelectServiceToEnable": "Select a provider with quality options to enable this option", "@downloadSelectServiceToEnable": { "description": "Hint shown instead of Ask-quality subtitle when selected provider has no quality options" diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 141536a0..120b2689 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -14,6 +14,22 @@ final _log = AppLogger('ExtensionProvider'); const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; const _spotifyWebExtensionId = 'spotify-web'; +const _storeRegistryUrlPrefKey = 'store_registry_url'; + +/// Result of restoring extensions from a backup. +class ExtensionRestoreResult { + final int installed; + final int alreadyPresent; + final int failed; + final List failedIds; + + const ExtensionRestoreResult({ + this.installed = 0, + this.alreadyPresent = 0, + this.failed = 0, + this.failedIds = const [], + }); +} bool _stringListEquals(List a, List b) { if (identical(a, b)) return true; @@ -1722,6 +1738,205 @@ class ExtensionNotifier extends Notifier { .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 _secretKeysFromManifest(Map raw) { + final keys = {}; + + 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> exportBackup({ + required bool includeSecrets, + }) async { + if (!PlatformBridge.supportsExtensionSystem) { + return {'registry_url': '', 'items': const >[]}; + } + + String registryUrl = ''; + try { + registryUrl = await PlatformBridge.getStoreRegistryUrl(); + } catch (_) {} + + List> installed; + try { + installed = await PlatformBridge.getInstalledExtensions(); + } catch (e) { + _log.w('Backup: failed to list extensions: $e'); + installed = const []; + } + + final items = >[]; + for (final raw in installed) { + final id = raw['id'] as String?; + if (id == null || id.isEmpty) continue; + final secretKeys = _secretKeysFromManifest(raw); + + Map settings = {}; + try { + settings = await PlatformBridge.getExtensionSettings(id); + } catch (_) {} + + final filtered = {}; + 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 restoreFromBackup( + Map 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((e) => Map.from(e)) + .toList() + : >[]; + + Directory? destDir; + try { + final tmp = await getTemporaryDirectory(); + destDir = await Directory( + '${tmp.path}/spotiflac_restore_ext', + ).create(recursive: true); + await PlatformBridge.initExtensionStore(destDir.path); + if (registryUrl.isNotEmpty) { + await PlatformBridge.setStoreRegistryUrl(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 = []; + + 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.downloadStoreExtension( + 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 = { + ...current, + ...Map.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( diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index cf58a3fd..58d420ae 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/backup_service.dart'; @@ -25,6 +26,7 @@ class _BackupRestorePageState extends ConsumerState { bool _isExporting = false; bool _isImporting = false; + bool _includeSecrets = false; bool get _isBusy => _isExporting || _isImporting; @@ -41,12 +43,16 @@ class _BackupRestorePageState extends ConsumerState { ); final collections = await collectionsNotifier.exportCollections(); final covers = await collectionsNotifier.exportPlaylistCovers(); + final extensions = await ref + .read(extensionProvider.notifier) + .exportBackup(includeSecrets: _includeSecrets); final envelope = BackupService.buildEnvelope( settings: settings, history: history, collections: collections, playlistCovers: covers, + extensions: extensions, ); final file = await BackupService.writeBackupFile(envelope); @@ -117,10 +123,26 @@ class _BackupRestorePageState extends ConsumerState { coverImages: bundle.playlistCovers, ); + ExtensionRestoreResult? extResult; + if (bundle.hasExtensions) { + extResult = await ref + .read(extensionProvider.notifier) + .restoreFromBackup(bundle.extensions); + } + + final message = StringBuffer(l10n.backupRestored) + ..write('\n') + ..write(l10n.backupRestoreRestartHint); + if (extResult != null && extResult.failed > 0) { + message + ..write('\n') + ..write(l10n.backupExtensionsRestoreFailed(extResult.failed)); + } + messenger.showSnackBar( SnackBar( - content: Text('${l10n.backupRestored}\n${l10n.backupRestoreRestartHint}'), - duration: const Duration(seconds: 5), + content: Text(message.toString()), + duration: const Duration(seconds: 6), ), ); } catch (e, stack) { @@ -182,6 +204,13 @@ class _BackupRestorePageState extends ConsumerState { bundle.favoriteArtistCount, ), ), + if (bundle.extensionCount > 0) + _ContentRow( + icon: Icons.extension_outlined, + label: l10n.backupContentsExtensions( + bundle.extensionCount, + ), + ), ], ), actions: [ @@ -259,6 +288,20 @@ class _BackupRestorePageState extends ConsumerState { buttonIcon: Icons.ios_share, isBusy: _isExporting, onPressed: _isBusy ? null : _createBackup, + extra: Padding( + padding: const EdgeInsets.only(top: 4, bottom: 4), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _includeSecrets, + onChanged: _isBusy + ? null + : (value) => + setState(() => _includeSecrets = value), + title: Text(l10n.backupIncludeSecrets), + subtitle: Text(l10n.backupIncludeSecretsDescription), + isThreeLine: true, + ), + ), ), const SizedBox(height: 16), _ActionCard( @@ -288,6 +331,7 @@ class _ActionCard extends StatelessWidget { final IconData buttonIcon; final bool isBusy; final VoidCallback? onPressed; + final Widget? extra; const _ActionCard({ required this.icon, @@ -297,6 +341,7 @@ class _ActionCard extends StatelessWidget { required this.buttonIcon, required this.isBusy, required this.onPressed, + this.extra, }); @override @@ -333,8 +378,8 @@ class _ActionCard extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 16), - FilledButton.icon( + ?extra, + const SizedBox(height: 16), FilledButton.icon( onPressed: onPressed, icon: isBusy ? const SizedBox( diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 3d18a987..79e009ca 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -25,6 +25,9 @@ class BackupBundle { /// Playlist cover images keyed by playlist id: `{ id: { ext, data } }`. final Map playlistCovers; + /// Extensions section: `{ registry_url, items: [ {id, version, enabled, settings} ] }`. + final Map extensions; + const BackupBundle({ required this.formatVersion, required this.appVersion, @@ -33,6 +36,7 @@ class BackupBundle { required this.history, required this.collections, required this.playlistCovers, + required this.extensions, }); bool get hasSettings => settings != null && settings!.isNotEmpty; @@ -49,13 +53,21 @@ class BackupBundle { int get playlistCount => _collectionListCount('playlists'); int get favoriteArtistCount => _collectionListCount('favoriteArtists'); + int get extensionCount { + final items = extensions['items']; + return items is List ? items.length : 0; + } + + bool get hasExtensions => extensionCount > 0; + bool get isEmpty => !hasSettings && historyCount == 0 && likedCount == 0 && wishlistCount == 0 && playlistCount == 0 && - favoriteArtistCount == 0; + favoriteArtistCount == 0 && + extensionCount == 0; } /// Builds and parses SpotiFLAC backup files (a single JSON document containing @@ -73,6 +85,7 @@ class BackupService { required List> history, required Map collections, required Map playlistCovers, + required Map extensions, }) { return { 'magic': magic, @@ -85,6 +98,7 @@ class BackupService { 'history': history, 'collections': collections, 'playlist_covers': playlistCovers, + 'extensions': extensions, }, }; } @@ -165,6 +179,11 @@ class BackupService { ? Map.from(coversRaw) : {}; + final extensionsRaw = data['extensions']; + final extensions = extensionsRaw is Map + ? Map.from(extensionsRaw) + : {}; + return BackupBundle( formatVersion: (root['format_version'] as num?)?.toInt() ?? 1, appVersion: root['app_version'] as String? ?? '', @@ -173,6 +192,7 @@ class BackupService { history: history, collections: collections, playlistCovers: playlistCovers, + extensions: extensions, ); } }