diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index ee4c29c2..7cf6d487 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index d284b586..db9ffce9 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 2a01d5a4..fadc6aa0 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index ba8a06fe..a36f8064 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index d2ad04f3..2ba8ff57 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "Plezy could not start", "failedBody": "Something went wrong during startup. The details below identify what failed.", + "failedBodyRepairable": "Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.", "phaseLabel": "Step", "showDetails": "Show details", "hideDetails": "Hide details", @@ -997,7 +998,9 @@ "repairTitle": "Repair stored data?", "repairBodyCommon": "Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.", "repairBodyOneCredential": "One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.", - "repairBodySessionsAtRisk": "Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.", + "repairBodySignInsKept": "Your servers and profiles should stay signed in.", + "repairBodySignInsLost": "The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.", + "repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.", "repairConfirm": "Repair", "repairSucceeded": "Storage repaired", "repairNeedsRestart": "Storage repaired — restart required", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 433015ea..3cd67a0a 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 257f12e9..b7c33922 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index b5dbb015..4468be7b 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 167c8ed5..e6b2480e 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 1c603718..c0091266 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -986,6 +986,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -996,7 +997,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 2e37d939..ed2feca2 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index ba597532..b1623981 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -986,6 +986,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -996,7 +997,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 377eee6f..e88db695 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index cace869a..7a154197 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index eddf2fc9..dddee436 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -989,6 +989,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -999,7 +1000,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index ca2da9d3..f8fb55e1 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 0bd332b7..0cd72be2 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -989,6 +989,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -999,7 +1000,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index e4d8b38f..7b48f615 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32942 (1497 per locale) +/// Strings: 32945 (1497 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index af39dd55..ace9d2e6 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2853,6 +2853,9 @@ class Translations$startup$en { /// en: 'Something went wrong during startup. The details below identify what failed.' String get failedBody => 'Something went wrong during startup. The details below identify what failed.'; + /// en: 'Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.' + String get failedBodyRepairable => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.'; + /// en: 'Step' String get phaseLabel => 'Step'; @@ -2883,8 +2886,14 @@ class Translations$startup$en { /// en: 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.' String get repairBodyOneCredential => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.'; - /// en: 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.' - String get repairBodySessionsAtRisk => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.'; + /// en: 'Your servers and profiles should stay signed in.' + String get repairBodySignInsKept => 'Your servers and profiles should stay signed in.'; + + /// en: 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.' + String get repairBodySignInsLost => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.'; + + /// en: 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.' + String get repairBodySessionsUncertain => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.'; /// en: 'Repair' String get repairConfirm => 'Repair'; @@ -6937,6 +6946,7 @@ extension on Translations { 'logs.uploadLogs' => 'Upload Logs', 'startup.failedTitle' => 'Plezy could not start', 'startup.failedBody' => 'Something went wrong during startup. The details below identify what failed.', + 'startup.failedBodyRepairable' => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.', 'startup.phaseLabel' => 'Step', 'startup.showDetails' => 'Show details', 'startup.hideDetails' => 'Hide details', @@ -6947,7 +6957,9 @@ extension on Translations { 'startup.repairTitle' => 'Repair stored data?', 'startup.repairBodyCommon' => 'Plezy\'s settings file is damaged and cannot be read. Repairing resets every setting to its default.', 'startup.repairBodyOneCredential' => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.', - 'startup.repairBodySessionsAtRisk' => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.', + 'startup.repairBodySignInsKept' => 'Your servers and profiles should stay signed in.', + 'startup.repairBodySignInsLost' => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.', + 'startup.repairBodySessionsUncertain' => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.', 'startup.repairConfirm' => 'Repair', 'startup.repairSucceeded' => 'Storage repaired', 'startup.repairNeedsRestart' => 'Storage repaired — restart required', @@ -7045,11 +7057,11 @@ extension on Translations { 'explore.season.winter' => 'Winter', 'explore.season.spring' => 'Spring', 'explore.season.summer' => 'Summer', + _ => null, + } ?? switch (path) { 'explore.season.fall' => 'Fall', 'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}', 'explore.format.tv' => 'TV', - _ => null, - } ?? switch (path) { 'explore.format.tvShort' => 'TV Short', 'explore.format.movie' => 'Movie', 'explore.format.special' => 'Special', @@ -7559,11 +7571,11 @@ extension on Translations { 'externalPlayer.off' => 'Off', 'externalPlayer.launchFailed' => 'Failed to open external player', 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed', + _ => null, + } ?? switch (path) { 'externalPlayer.playInExternalPlayer' => 'Play in External Player', 'metadataEdit.editMetadata' => 'Edit...', 'metadataEdit.screenTitle' => 'Edit Metadata', - _ => null, - } ?? switch (path) { 'metadataEdit.basicInfo' => 'Basic Info', 'metadataEdit.artwork' => 'Artwork', 'metadataEdit.advancedSettings' => 'Advanced Settings', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index a7b8a69d..07760835 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index f0f76d80..a0e969a7 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 5579b6e2..56fc206d 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -987,6 +987,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -997,7 +998,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index af91c149..6a8a1f6c 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -986,6 +986,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -996,7 +997,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index babe0ba5..ba42c82f 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -986,6 +986,7 @@ "startup": { "failedTitle": "", "failedBody": "", + "failedBodyRepairable": "", "phaseLabel": "", "showDetails": "", "hideDetails": "", @@ -996,7 +997,9 @@ "repairTitle": "", "repairBodyCommon": "", "repairBodyOneCredential": "", - "repairBodySessionsAtRisk": "", + "repairBodySignInsKept": "", + "repairBodySignInsLost": "", + "repairBodySessionsUncertain": "", "repairConfirm": "", "repairSucceeded": "", "repairNeedsRestart": "", diff --git a/lib/main.dart b/lib/main.dart index d37cc5f9..2ba36745 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -85,6 +85,7 @@ import 'utils/android_exit_diagnostics.dart'; import 'utils/storage_failure.dart'; import 'services/base_shared_preferences_service.dart'; import 'services/prefs_recovery.dart'; +import 'services/sensitive_prefs.dart'; import 'services/startup_diagnostics.dart'; import 'utils/dialogs.dart'; import 'widgets/dialog_action_button.dart'; @@ -432,16 +433,21 @@ Future repairStartupStorage( final unreadableKey = cause is UnreadableSensitivePreferenceException ? cause.key : null; final reopenSafe = cause is! CorruptPreferenceStoreException || cause.reopenSafe; - // Name the cost before touching anything. A salvaged vault key keeps servers - // and profiles signed in because those tokens are ciphertext in the - // database; tracker and Seerr sessions are plaintext preference entries and - // can still be lost, so neither branch promises more than it can deliver. + // Name the real cost before touching anything, not the usual one. Servers + // and profiles survive a repair only because their tokens are ciphertext in + // the database and the key that decrypts them lives in the store — so a + // store the key cannot be read out of signs the user out of everything. An + // all-zero file is exactly that case (#1732), and telling them their + // sign-ins are safe would be a promise the outcome dialog then contradicts. + final signInsSurvive = unreadableKey != null + ? unreadableKey != credentialVaultKeyPref + : (await PrefsRecovery.previewSalvage()).vaultKey != null; + if (!context.mounted) return StartupRepairResult.none; + final confirmed = await showConfirmDialog( context, title: t.startup.repairTitle, - message: unreadableKey != null - ? '${t.startup.repairBodyOneCredential}\n\n${t.startup.repairBodySessionsAtRisk}' - : '${t.startup.repairBodyCommon}\n\n${t.startup.repairBodySessionsAtRisk}', + message: repairConsentMessage(oneCredential: unreadableKey != null, signInsSurvive: signInsSurvive), confirmText: t.startup.repairConfirm, isDestructive: true, ); @@ -457,11 +463,41 @@ Future repairStartupStorage( return result; } -/// Reports a completed repair, including the credential-bearing backup. +/// The consent dialog's body: what is being repaired, then what it costs. +/// +/// Separated from [repairStartupStorage] because the two halves fail +/// differently. Deciding whether the sign-ins survive is I/O against a +/// damaged file; deciding what to *tell* the user about it is a promise, and +/// #1732 is what happens when the promise is written once, optimistically, +/// for a case that does not always hold. +/// +/// [signInsSurvive] covers servers and profiles only. Their tokens are +/// ciphertext in the database, so they live or die with the vault key — +/// which is exactly why that one value decides this sentence. +/// +/// Tracker and Seerr sessions are plaintext preference entries, salvaged and +/// reseeded one by one and untouched entirely by the single-credential +/// repair. They therefore do not follow the vault key in either direction, +/// and get one cautious sentence in both branches rather than a promise +/// borrowed from a value that does not govern them. +@visibleForTesting +String repairConsentMessage({required bool oneCredential, required bool signInsSurvive}) => [ + oneCredential ? t.startup.repairBodyOneCredential : t.startup.repairBodyCommon, + signInsSurvive ? t.startup.repairBodySignInsKept : t.startup.repairBodySignInsLost, + t.startup.repairBodySessionsUncertain, +].join('\n\n'); + +/// Reports a completed repair, including the retained copy of the damaged +/// store. /// /// The backup path is shown so the user can find and delete it, never so it -/// can be attached to a report: it holds the credential-vault key, tracker -/// refresh tokens and Seerr cookies in plaintext. +/// can be attached to a report: it can hold the credential-vault key, tracker +/// refresh tokens and Seerr cookies in plaintext. The warning is suppressed +/// only when the quarantined *bytes* prove there is nothing in them — an +/// all-zero file (#1732) — never on the strength of what the salvage managed +/// to recover, which says nothing about what is still in the file. A warning +/// on a file of zeros is the one that teaches users to ignore the warning +/// that matters. @visibleForTesting Future showRepairOutcomeDialog( BuildContext context, @@ -492,8 +528,10 @@ Future showRepairOutcomeDialog( Text(t.startup.backupTitle, style: Theme.of(dialogContext).textTheme.titleSmall), const SizedBox(height: 4), SelectableText(backupPath, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), - const SizedBox(height: 4), - Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)), + if (outcome.backupHoldsCredentials) ...[ + const SizedBox(height: 4), + Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)), + ], const SizedBox(height: 8), DialogActionButton( label: t.startup.deleteBackup, diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index 54634e7b..f1d34e99 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -139,7 +139,12 @@ abstract class BaseSharedPreferencesService { /// unreadable value — quarantining that one would reopen onto the stale /// in-memory map and write the bad value straight back. static Future repairCorruptStore({bool reopenSafe = true}) async { - final (:salvaged, :backupPath) = await PrefsRecovery.quarantine(); + final (:salvaged, :backupPath, :shape) = await PrefsRecovery.quarantine(); + // Conservative by construction: the warning is dropped only for bytes + // proven to contain nothing. What the salvage recovered cannot stand in + // for that — a store truncated mid-value matches no entry at all while + // still holding most of a vault key in plaintext. + final backupHoldsCredentials = !(shape?.allZero ?? false); _resetGeneration++; _initializations.clear(); @@ -158,6 +163,7 @@ abstract class BaseSharedPreferencesService { appLogger.w('Preference store quarantined; a restart is required before it can be reopened'); return PrefsRepairOutcome( backupPath: backupPath, + backupHoldsCredentials: backupHoldsCredentials, vaultKeySalvaged: seeded && salvaged.vaultKey != null, sessionsSalvaged: seeded ? salvaged.sessions.length : 0, sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length, @@ -190,6 +196,7 @@ abstract class BaseSharedPreferencesService { return PrefsRepairOutcome( backupPath: backupPath, + backupHoldsCredentials: backupHoldsCredentials, vaultKeySalvaged: salvaged.vaultKey != null, sessionsSalvaged: salvaged.sessions.length, sessionsLost: salvaged.losses, diff --git a/lib/services/prefs_recovery.dart b/lib/services/prefs_recovery.dart index 54037cb4..77014578 100644 --- a/lib/services/prefs_recovery.dart +++ b/lib/services/prefs_recovery.dart @@ -142,14 +142,26 @@ class PrefsRepairOutcome { required this.vaultKeySalvaged, required this.sessionsSalvaged, required this.sessionsLost, + this.backupHoldsCredentials = true, this.settingsReset = true, this.requiresRestart = false, }); - /// Absolute path of the quarantined store. It contains credentials in - /// plaintext: never upload it, attach it to a report, or log its contents. + /// Absolute path of the quarantined store. When [backupHoldsCredentials] it + /// contains credentials in plaintext: never upload it, attach it to a + /// report, or log its contents. final String? backupPath; + /// Whether the quarantined file may still hold credential material. + /// + /// Derived from the bytes, never from what the salvage recovered: a store + /// truncated mid-value keeps most of a vault key or an entire refresh token + /// in plaintext while matching no entry at all, because the salvage pattern + /// requires the value's closing quote. Only a file proven to contain + /// nothing — every byte zero (#1732) — drops the warning, because a warning + /// attached to a file of zeros is the one that teaches users to ignore it. + final bool backupHoldsCredentials; + /// Whether [credentialVaultKeyPref] was recovered. When false every stored /// server and profile token becomes undecryptable and must be re-acquired. final bool vaultKeySalvaged; @@ -187,6 +199,11 @@ class SalvagedPrefsCredentials { /// Credential slots that were present but could not be decoded. final int losses; + + /// Deliberately no "does this hold credentials" helper. What [salvage] + /// recovered says nothing about what the file still contains: a store + /// truncated mid-value keeps most of a vault key in plaintext and matches + /// nothing. Ask [PrefsStoreShape] about the bytes instead. } /// Recovers a damaged desktop preference store without silently destroying @@ -448,28 +465,73 @@ abstract final class PrefsRecovery { static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-'); - /// Quarantines the damaged store and returns what was salvaged. + /// Reports what a repair would recover, without touching the store. + /// + /// The consent dialog has to name a cost before anything is destroyed, and + /// the cost is not uniform: a store that still holds a readable vault key + /// keeps every server and profile signed in, while one that does not — an + /// all-zero file being the canonical case (#1732) — signs the user out of + /// everything. Promising the cheap outcome for the expensive case is worse + /// than saying nothing, so the dialog runs this first and branches on it. + /// + /// Pure with respect to the filesystem. [salvage] does register every secret + /// it decodes with `LogRedactionManager`, which is idempotent and wanted + /// early regardless of whether the user goes on to accept the repair. + static Future previewSalvage({File? storeFileOverride}) async { + final File file; + try { + file = storeFileOverride ?? await storeFile(); + if (!await file.exists()) return SalvagedPrefsCredentials.empty; + } on Object { + return SalvagedPrefsCredentials.empty; + } + + try { + return salvage(_decodeForSalvage(await file.readAsBytes())); + } on FileSystemException catch (error, stackTrace) { + // Only a preview; an unreadable file is the repair's problem to report, + // not a reason to fail before the user has even been asked. + appLogger.w('Could not preview the damaged preference store', error: error, stackTrace: stackTrace); + return SalvagedPrefsCredentials.empty; + } + } + + /// Decodes damaged store bytes as text for the salvage pass. + /// + /// Lossy rather than strict, because `File.readAsString` surfaces a UTF-8 + /// decode failure as a `FileSystemException` and would abort on exactly the + /// damaged store this exists to rescue. For a well-formed document this is + /// identical to a strict decode; for a damaged one it still exposes the + /// ASCII credential entries. + static String _decodeForSalvage(List bytes) => const Utf8Decoder(allowMalformed: true).convert(bytes); + + /// Quarantines the damaged store and returns what was salvaged, alongside + /// the measured shape of the bytes that were moved aside. /// /// The caller opens a fresh store afterwards and reseeds it; see /// `BaseSharedPreferencesService.repairCorruptStore`. /// /// The damaged file is *moved*, never deleted: it is the only remaining copy /// of any credential that could not be salvaged. - static Future<({SalvagedPrefsCredentials salvaged, String? backupPath})> quarantine({File? storeFileOverride}) async { + /// + /// [shape] is returned rather than inferred from [salvage] because the two + /// answer different questions. Salvage reports what could be *recovered*; + /// only the bytes can say whether anything sensitive is still *in there*. + /// A store truncated mid-value — the ordinary damage shape — holds most of a + /// vault key or an entire refresh token in plaintext while + /// [_stringEntryPattern] matches nothing at all, because it requires the + /// value's closing quote. + static Future<({SalvagedPrefsCredentials salvaged, String? backupPath, PrefsStoreShape? shape})> quarantine({ + File? storeFileOverride, + }) async { final file = storeFileOverride ?? await storeFile(); if (!await file.exists()) { - return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null); + return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null, shape: null); } - // Read bytes and decode lossily rather than calling `readAsString`, which - // surfaces a UTF-8 decode failure as a `FileSystemException` and would - // abort the repair on exactly the damaged store it exists to rescue. For a - // well-formed document this is identical to a strict decode; for a damaged - // one it still exposes the ASCII credential entries to the salvage pass. - // A genuine read failure propagates — that is not something a repair fixes. - final raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes()); - - final salvaged = salvage(raw); + final bytes = await file.readAsBytes(); + final shape = PrefsStoreShape.of(bytes); + final salvaged = salvage(_decodeForSalvage(bytes)); final stamp = _stamp(); final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json')); @@ -489,10 +551,10 @@ abstract final class PrefsRecovery { appLogger.w( 'Quarantined a corrupt preference store' - ' (vault key salvaged: ${salvaged.vaultKey != null},' + ' ($shape; vault key salvaged: ${salvaged.vaultKey != null},' ' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})', ); - return (salvaged: salvaged, backupPath: backup.path); + return (salvaged: salvaged, backupPath: backup.path, shape: shape); } /// Deletes a quarantined store so the user is not left holding a diff --git a/lib/services/startup_diagnostics.dart b/lib/services/startup_diagnostics.dart index 9749b7ac..febd4967 100644 --- a/lib/services/startup_diagnostics.dart +++ b/lib/services/startup_diagnostics.dart @@ -206,6 +206,11 @@ class StartupFailureRecord { String get headline => '[$phaseId] $errorType: $message'; /// Full plain-text block for the clipboard and the diagnostics upload. + /// + /// Includes [repairable] because the uploaded text is usually all a + /// maintainer gets. Without it a report of "still broken" cannot be told + /// apart from "the screen offered no way forward" and "a repair was offered + /// and not taken" — the ambiguity that stalled #1732 for two days. String describe() { final buffer = StringBuffer() ..writeln('Plezy startup failure') @@ -214,6 +219,7 @@ class StartupFailureRecord { ..writeln('When: ${timestamp.toUtc().toIso8601String()}') ..writeln('Phase: $phaseId') ..writeln('Error: $errorType') + ..writeln('Repair offered: ${repairable ? 'yes' : 'no'}') ..writeln('Message: $message'); final stack = stackTrace; if (stack != null && stack.isNotEmpty) { diff --git a/lib/widgets/startup_failure_view.dart b/lib/widgets/startup_failure_view.dart index 87d30420..07a9c025 100644 --- a/lib/widgets/startup_failure_view.dart +++ b/lib/widgets/startup_failure_view.dart @@ -69,13 +69,15 @@ class StartupFailureView extends StatefulWidget { } class _StartupFailureViewState extends State { - late final FocusNode _retryFocusNode = FocusNode(debugLabel: 'startup-failure-retry'); + /// Holds whichever action leads this screen — Repair when it is offered, + /// Retry otherwise, Quit once a restart is owed. + late final FocusNode _primaryFocusNode = FocusNode(debugLabel: 'startup-failure-primary'); bool _detailsExpanded = false; bool _uploading = false; @override void dispose() { - _retryFocusNode.dispose(); + _primaryFocusNode.dispose(); super.dispose(); } @@ -162,7 +164,11 @@ class _StartupFailureViewState extends State { ), const SizedBox(height: 8), Text( - restartRequired ? t.startup.restartRequiredBody : t.startup.failedBody, + restartRequired + ? t.startup.restartRequiredBody + : repair != null + ? t.startup.failedBodyRepairable + : t.startup.failedBody, key: restartRequired ? startupFailureRestartKey : null, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, @@ -181,29 +187,45 @@ class _StartupFailureViewState extends State { spacing: 12, runSpacing: 12, children: [ - if (!restartRequired) - FocusableButton( - focusNode: _retryFocusNode, - autofocus: true, - onPressed: canAct ? widget.onRetry : null, - child: FilledButton( - key: startupBootstrapRetryKey, - onPressed: canAct ? widget.onRetry : null, - child: Text(t.common.retry), - ), - ), + // Repair leads whenever it is offered. The failure it + // addresses is a document on disk that Retry re-reads + // unchanged, so Retry cannot clear it however many times it + // is pressed — and it used to be the primary, autofocused, + // first-in-order action, which is what a reporter on #1732 + // pressed repeatedly before concluding the fix had not + // shipped. Retry keeps its place for every other failure, + // where the environment really can change between attempts. if (!restartRequired && repair != null) FocusableButton( + focusNode: _primaryFocusNode, + autofocus: true, onPressed: canAct ? () => repair() : null, - child: FilledButton.tonal( + child: FilledButton( key: startupFailureRepairKey, onPressed: canAct ? () => repair() : null, child: Text(t.startup.repairStorage), ), ), + if (!restartRequired) + FocusableButton( + focusNode: repair == null ? _primaryFocusNode : null, + autofocus: repair == null, + onPressed: canAct ? widget.onRetry : null, + child: repair == null + ? FilledButton( + key: startupBootstrapRetryKey, + onPressed: canAct ? widget.onRetry : null, + child: Text(t.common.retry), + ) + : OutlinedButton( + key: startupBootstrapRetryKey, + onPressed: canAct ? widget.onRetry : null, + child: Text(t.common.retry), + ), + ), if (restartRequired && PlatformDetector.isDesktopOS()) FocusableButton( - focusNode: _retryFocusNode, + focusNode: _primaryFocusNode, autofocus: true, onPressed: enabled ? _quit : null, child: FilledButton( diff --git a/test/services/prefs_store_repair_flow_test.dart b/test/services/prefs_store_repair_flow_test.dart index 314b21ba..5f5efc3d 100644 --- a/test/services/prefs_store_repair_flow_test.dart +++ b/test/services/prefs_store_repair_flow_test.dart @@ -146,6 +146,80 @@ void main() { await expectLater(SettingsService.getInstance(), completes); }); + test('the all-zero store reported in #1732 is repairable and costs every sign-in', () async { + // The store the reporter actually had: a full-length file whose every byte + // is zero. It reaches the parser as "FormatException at offset 0", exactly + // like a truncated document, but nothing can be salvaged from it — which + // is the opposite of harmless, because the vault key goes with it. + await store.writeAsBytes(List.filled(10336, 0)); + + final error = await SettingsService.getInstance().then((_) => null, onError: (Object e) => e); + final corruption = error! as CorruptPreferenceStoreException; + expect(corruption.shape?.allZero, isTrue); + expect(corruption.shape?.length, 10336); + + // The consent dialog reads this before destroying anything, and must not + // promise that servers and profiles survive. + final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store); + expect(preview.vaultKey, isNull); + expect(preview.sessions, isEmpty); + // Purely a preview: the damaged store is still exactly where it was. + expect(await store.exists(), isTrue); + expect(await store.length(), 10336); + + // Retrying re-reads the same bytes and reaches the same verdict, so the + // failure screen's Retry button can never clear this on its own. + await expectLater(SettingsService.getInstance(), throwsA(isA())); + + final outcome = await BaseSharedPreferencesService.repairCorruptStore(); + + expect(outcome.requiresRestart, isFalse); + expect(outcome.vaultKeySalvaged, isFalse); + // A file of zeros is not a secret, so the outcome dialog must not tell the + // user the retained copy holds their credentials. + expect(outcome.backupHoldsCredentials, isFalse); + // The repair is what actually gets them into the app, in this process. + await expectLater(SettingsService.getInstance(), completes); + }); + + test('a salvageable store previews as keeping its sign-ins', () async { + await store.writeAsBytes([ + 0, + ...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey})), + ]); + + final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store); + + expect(preview.vaultKey, validVaultKey); + + final outcome = await BaseSharedPreferencesService.repairCorruptStore(); + expect(outcome.vaultKeySalvaged, isTrue); + expect(outcome.backupHoldsCredentials, isTrue); + }); + + test('a store truncated mid-credential still warns that the copy holds secrets', () async { + // The ordinary damage shape, and the one that makes "what did salvage + // recover" useless as a proxy for "what is still in the file". The value + // has no closing quote, so the salvage pattern matches nothing and reports + // no losses — while almost the entire vault key sits in the quarantined + // copy in plaintext. + final truncated = '{"$credentialVaultKeyPref":"${validVaultKey.substring(0, validVaultKey.length - 4)}'; + await store.writeAsString(truncated); + + final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store); + expect(preview.vaultKey, isNull); + expect(preview.sessions, isEmpty); + expect(preview.losses, 0); + + final outcome = await BaseSharedPreferencesService.repairCorruptStore(); + + expect(outcome.vaultKeySalvaged, isFalse); + // The copy is unmistakably sensitive even though nothing was recoverable. + expect(outcome.backupHoldsCredentials, isTrue); + final quarantined = await File(outcome.backupPath!).readAsString(); + expect(quarantined, contains(validVaultKey.substring(0, 20))); + }); + test('a genuine read failure keeps its own type and stays non-repairable', () async { // A store the process cannot read is not a damaged *document*. Offering a // destructive repair for a permissions problem would reset every setting diff --git a/test/services/startup_diagnostics_test.dart b/test/services/startup_diagnostics_test.dart index c8564753..26ac9ce8 100644 --- a/test/services/startup_diagnostics_test.dart +++ b/test/services/startup_diagnostics_test.dart @@ -13,12 +13,14 @@ StartupFailureRecord _record({ StartupPhase? phase = StartupPhase.database, Object error = const FormatException('boom'), StackTrace? stackTrace, + bool repairable = false, }) => StartupFailureRecord.fromError( phase: phase, error: error, stackTrace: stackTrace, appVersion: '2.11.0+124', platform: 'windows 11', + repairable: repairable, ); void main() { @@ -120,6 +122,14 @@ void main() { expect(text, contains('windows 11')); }); + test('describe() states whether a repair was on offer', () { + // An uploaded record is usually the only artefact a maintainer gets. On + // #1732 it could not distinguish "the screen offered no way forward" + // from "a repair was offered and not taken", and the thread stalled. + expect(_record(repairable: true).describe(), contains('Repair offered: yes')); + expect(_record().describe(), contains('Repair offered: no')); + }); + test('headline stays one line', () { expect(_record().headline, startsWith('[database] FormatException')); }); diff --git a/test/widgets/startup_failure_view_test.dart b/test/widgets/startup_failure_view_test.dart index de9582c7..7d68ef7c 100644 --- a/test/widgets/startup_failure_view_test.dart +++ b/test/widgets/startup_failure_view_test.dart @@ -21,6 +21,7 @@ Future _pumpView( WidgetTester tester, { required bool restartRequired, Future Function()? onExitRequested, + bool repairable = true, }) async { await tester.pumpWidget( TranslationProvider( @@ -30,7 +31,7 @@ Future _pumpView( failure: _record(), restartRequired: restartRequired, onRetry: () {}, - onRepair: () async {}, + onRepair: repairable ? () async {} : null, requestExit: ({AppExitApplication? exitApplicationForTesting}) async => onExitRequested == null ? false : await onExitRequested(), ), @@ -41,6 +42,14 @@ Future _pumpView( await tester.pump(); } +/// Whether [key] resolves to the filled (primary) Material button. +bool _isPrimary(WidgetTester tester, Key key) => tester.widget(find.byKey(key)) is FilledButton; + +/// Whether [key]'s enclosing focusable currently holds focus. +bool _hasFocus(WidgetTester tester, Key key) => + tester.widget(find.ancestor(of: find.byKey(key), matching: find.byType(Focus)).first).focusNode?.hasFocus ?? + false; + void main() { setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); @@ -53,6 +62,31 @@ void main() { expect(find.byKey(startupFailureRestartKey), findsNothing); }); + testWidgets('repair leads the screen whenever it is offered', (tester) async { + await _pumpView(tester, restartRequired: false); + + // Retry re-reads the same damaged document, so it can never clear this + // failure. Presenting it as the primary, autofocused action is what left + // the #1732 reporter pressing it and concluding the fix had not shipped. + expect(_isPrimary(tester, startupFailureRepairKey), isTrue); + expect(_isPrimary(tester, startupBootstrapRetryKey), isFalse); + await tester.pump(); + expect(_hasFocus(tester, startupFailureRepairKey), isTrue); + expect(find.text(t.startup.failedBodyRepairable), findsOneWidget); + }); + + testWidgets('retry leads when no repair can address the failure', (tester) async { + await _pumpView(tester, restartRequired: false, repairable: false); + + // A locked database or a denied directory can genuinely change between + // attempts, so Retry keeps both the primary styling and the focus there. + expect(find.byKey(startupFailureRepairKey), findsNothing); + expect(_isPrimary(tester, startupBootstrapRetryKey), isTrue); + await tester.pump(); + expect(_hasFocus(tester, startupBootstrapRetryKey), isTrue); + expect(find.text(t.startup.failedBody), findsOneWidget); + }); + testWidgets('a pending restart withdraws every action that would touch the store', (tester) async { await _pumpView(tester, restartRequired: true); diff --git a/test/widgets/startup_repair_outcome_test.dart b/test/widgets/startup_repair_outcome_test.dart index e106e8bd..0eba3a43 100644 --- a/test/widgets/startup_repair_outcome_test.dart +++ b/test/widgets/startup_repair_outcome_test.dart @@ -59,6 +59,71 @@ void main() { expect(find.text(backup.path), findsOneWidget); }); + testWidgets('a backup with nothing in it still offers deletion but claims no secrets', (tester) async { + // The #1732 store was 10336 zero bytes. Telling the user that copy holds + // their sign-ins is false, and a warning that cries wolf is the one they + // will ignore on the launch where the file really is sensitive. + await _openDialog( + tester, + PrefsRepairOutcome( + backupPath: backup.path, + backupHoldsCredentials: false, + vaultKeySalvaged: false, + sessionsSalvaged: 0, + sessionsLost: 0, + ), + ); + + expect(find.text(t.startup.backupTitle), findsOneWidget); + expect(find.text(backup.path), findsOneWidget); + expect(find.text(t.startup.backupWarning), findsNothing); + expect(find.text(t.startup.deleteBackup), findsOneWidget); + }); + + group('consent copy', () { + test('a store with no recoverable vault key promises nothing it cannot keep', () { + final message = repairConsentMessage(oneCredential: false, signInsSurvive: false); + + // The #1732 store salvaged nothing. Saying "servers and profiles + // normally stay signed in" here would be contradicted by the outcome + // dialog moments later. + expect(message, contains(t.startup.repairBodyCommon)); + expect(message, contains(t.startup.repairBodySignInsLost)); + expect(message, isNot(contains(t.startup.repairBodySignInsKept))); + }); + + test('a salvageable store keeps the cheaper promise', () { + final message = repairConsentMessage(oneCredential: false, signInsSurvive: true); + + expect(message, contains(t.startup.repairBodySignInsKept)); + expect(message, isNot(contains(t.startup.repairBodySignInsLost))); + }); + + test('the single-credential repair names its own narrower scope', () { + final message = repairConsentMessage(oneCredential: true, signInsSurvive: true); + + expect(message, contains(t.startup.repairBodyOneCredential)); + expect(message, isNot(contains(t.startup.repairBodyCommon))); + }); + + test('trackers and Seerr get the same cautious sentence either way', () { + // Their sessions are plaintext preference entries, salvaged one by one + // and left untouched by the single-credential repair, so they neither + // survive nor die with the vault key. Neither branch may borrow that + // value's verdict for them. + for (final signInsSurvive in [true, false]) { + expect( + repairConsentMessage(oneCredential: false, signInsSurvive: signInsSurvive), + contains(t.startup.repairBodySessionsUncertain), + ); + } + expect( + repairConsentMessage(oneCredential: true, signInsSurvive: false), + contains(t.startup.repairBodySessionsUncertain), + ); + }); + }); + testWidgets('deleting the backup removes the file and stops showing its path', (tester) async { final deleted = []; await _openDialog(