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.
This commit is contained in:
zarzet
2026-06-26 20:11:51 +07:00
parent b2705cb2ae
commit 1787059f42
21 changed files with 989 additions and 5 deletions
+42
View File
@@ -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:
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+34
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+40
View File
@@ -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';
+38
View File
@@ -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"
+30
View File
@@ -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"
+215
View File
@@ -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<String> failedIds;
const ExtensionRestoreResult({
this.installed = 0,
this.alreadyPresent = 0,
this.failed = 0,
this.failedIds = const [],
});
}
bool _stringListEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
@@ -1722,6 +1738,205 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
.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.getStoreRegistryUrl();
} 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.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 = <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.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 = <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>(
+49 -4
View File
@@ -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<BackupRestorePage> {
bool _isExporting = false;
bool _isImporting = false;
bool _includeSecrets = false;
bool get _isBusy => _isExporting || _isImporting;
@@ -41,12 +43,16 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
);
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<BackupRestorePage> {
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<BackupRestorePage> {
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<BackupRestorePage> {
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(
+21 -1
View File
@@ -25,6 +25,9 @@ class BackupBundle {
/// Playlist cover images keyed by playlist id: `{ id: { ext, data } }`.
final Map<String, dynamic> playlistCovers;
/// Extensions section: `{ registry_url, items: [ {id, version, enabled, settings} ] }`.
final Map<String, dynamic> 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<Map<String, dynamic>> history,
required Map<String, dynamic> collections,
required Map<String, dynamic> playlistCovers,
required Map<String, dynamic> 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<String, dynamic>.from(coversRaw)
: <String, dynamic>{};
final extensionsRaw = data['extensions'];
final extensions = extensionsRaw is Map
? Map<String, dynamic>.from(extensionsRaw)
: <String, dynamic>{};
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,
);
}
}