mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
fix: three-way logo panic gesture (instant / confirm / off)
Rebased onto #1509's merged shape. requestPanicWipe stays the single choke point and the confirmation dialog stays on ContentView where #1509 put it; this only folds the mode switch into that path and adds the disabled-gesture alert beside it. ContentHeaderView is untouched — main already routes the triple-tap through requestPanicWipe. Default is confirm, matching what #1509 shipped. The previous revision read panic.confirmLogoShortcut / panic.logoShortcutEnabled, keys that never shipped, so every existing install fell through to instant and silently lost the confirm-first behaviour on update. The migration is gone rather than fixed: with no key ever written there is nothing to migrate, and an unparseable value now falls back to confirm too. Panic wipe resets to confirm, so a wiped device comes back with the gesture armed rather than carrying the seized owner's off setting. Main's copy goes stale outside confirm mode, so the privacy row and the panic caption are now mode-aware via new keys; main's existing entries are unchanged.
This commit is contained in:
parent
9b84b36122
commit
ed0565ab2c
@ -23,6 +23,8 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
/// Triple-tapping the logo asks first; the dialog lives on the header.
|
||||
@Published var showPanicConfirmation = false
|
||||
/// Triple-tap while the gesture is off. See `requestPanicWipe`.
|
||||
@Published var showPanicGestureDisabledAlert = false
|
||||
/// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe
|
||||
/// that did not commit must be visible, not just logged — the person who
|
||||
/// triggered it needs to know data may remain on the device.
|
||||
@ -120,11 +122,23 @@ final class AppChromeModel: ObservableObject {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
/// Entry point for the header triple-tap: confirm before destroying.
|
||||
/// The Settings-pane button has always confirmed; the gesture now goes
|
||||
/// through the same dialog so a mis-tap can't wipe the device.
|
||||
/// Entry point for the header triple-tap. The Settings-pane button has
|
||||
/// always confirmed; the gesture goes through the same dialog by default
|
||||
/// so a mis-tap can't wipe the device.
|
||||
///
|
||||
/// The mode is resolved here rather than at the gesture site so that
|
||||
/// instant / confirm / off is decided at one choke point.
|
||||
func requestPanicWipe() {
|
||||
showPanicConfirmation = true
|
||||
switch PanicWipeSettings.logoShortcutMode {
|
||||
case .confirm:
|
||||
showPanicConfirmation = true
|
||||
case .instant:
|
||||
panicClearAllData()
|
||||
case .off:
|
||||
// A silent no-op is the worst outcome here: someone triple-tapping
|
||||
// under duress would believe the device was wiped. Say it wasn't.
|
||||
showPanicGestureDisabledAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
62
bitchat/Services/PanicWipeSettings.swift
Normal file
62
bitchat/Services/PanicWipeSettings.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// PanicWipeSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Controls how the undiscoverable `bitchat/` logo triple-tap behaves.
|
||||
///
|
||||
/// Defaults to **confirm**, which is exactly the behaviour #1509 shipped: the
|
||||
/// gesture is armed and asks before wiping. That matters more than it looks —
|
||||
/// the logo is also the single-tap App Info entry point, so a fumbled tap must
|
||||
/// not be able to wipe the device, and an update must not silently move anyone
|
||||
/// off the behaviour they already have.
|
||||
///
|
||||
/// People who need the seizure path — someone taking the phone out of your
|
||||
/// hand — can opt into an instant wipe. Turning the gesture off entirely is
|
||||
/// also allowed; the settings panic button still wipes after its own confirm.
|
||||
///
|
||||
/// There is no legacy-key migration: #1509 never wrote a preference key, so
|
||||
/// every existing install arrives here with nothing stored and takes the
|
||||
/// default.
|
||||
///
|
||||
/// One type, one `reset()`, one danger-zone control — do not split this across
|
||||
/// parallel settings enums.
|
||||
enum PanicWipeSettings {
|
||||
private static let modeKey = "panic.logoShortcutMode"
|
||||
|
||||
enum LogoShortcutMode: String, CaseIterable, Identifiable {
|
||||
case instant
|
||||
case confirm
|
||||
case off
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
static var logoShortcutMode: LogoShortcutMode {
|
||||
get { logoShortcutMode(in: .standard) }
|
||||
set { setLogoShortcutMode(newValue, in: .standard) }
|
||||
}
|
||||
|
||||
static func logoShortcutMode(in defaults: UserDefaults) -> LogoShortcutMode {
|
||||
guard let raw = defaults.string(forKey: modeKey),
|
||||
let mode = LogoShortcutMode(rawValue: raw) else {
|
||||
return .confirm
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
static func setLogoShortcutMode(_ mode: LogoShortcutMode, in defaults: UserDefaults) {
|
||||
defaults.set(mode.rawValue, forKey: modeKey)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook. Removing the key restores the confirm default, so a
|
||||
/// wiped device comes back with the gesture armed rather than absent.
|
||||
static func reset(in defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: modeKey)
|
||||
}
|
||||
}
|
||||
@ -1641,6 +1641,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
MeshSightingsTracker.shared.clear()
|
||||
MeshEchoSettings.reset()
|
||||
NotificationPrivacySettings.reset()
|
||||
// Back to confirm-first: a wiped device should come back with the
|
||||
// gesture armed, not silently off because that was the old setting.
|
||||
PanicWipeSettings.reset()
|
||||
// A hand-added relay names an operator someone chose to route through,
|
||||
// which is the kind of trace a wipe should not leave behind.
|
||||
NostrRelaySettings.reset()
|
||||
|
||||
@ -22,6 +22,8 @@ struct AppInfoView: View {
|
||||
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
|
||||
@State private var locationNotesEnabled = LocationNotesSettings.enabled
|
||||
@State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews
|
||||
@State private var logoShortcutMode = PanicWipeSettings.logoShortcutMode
|
||||
@State private var showDisableLogoShortcutConfirm = false
|
||||
@State private var customRelays = NostrRelaySettings.customRelays()
|
||||
@State private var relayInput = ""
|
||||
@State private var relayError: String?
|
||||
@ -121,6 +123,14 @@ struct AppInfoView: View {
|
||||
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
|
||||
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
|
||||
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
|
||||
static let panicNoteDisabled = String(localized: "app_info.settings.danger.panic_note_disabled", defaultValue: "erases all messages, keys, and identity. the bitchat/ logo triple-tap is off — only this button wipes.", comment: "Caption under the panic wipe button when the logo triple-tap is turned off")
|
||||
static let logoModeTitle = String(localized: "app_info.settings.danger.logo_mode.title", defaultValue: "logo triple-tap wipe", comment: "Title of the setting that chooses instant / confirm / off for the logo panic gesture")
|
||||
static let logoModeSubtitle = String(localized: "app_info.settings.danger.logo_mode.subtitle", defaultValue: "confirm first by default, so a mis-tap on the logo cannot wipe the device. instant skips the dialog for the seizure path. off removes the gesture — turning it off asks for confirmation, because a disabled triple-tap is a silent failure in a panic.", comment: "Subtitle explaining the three logo panic gesture modes")
|
||||
static let logoModeInstant = String(localized: "app_info.settings.danger.logo_mode.instant", defaultValue: "instant", comment: "Logo panic mode: wipe immediately on triple-tap")
|
||||
static let logoModeConfirm = String(localized: "app_info.settings.danger.logo_mode.confirm", defaultValue: "confirm first", comment: "Logo panic mode: ask before wiping on triple-tap")
|
||||
static let logoModeOff = String(localized: "app_info.settings.danger.logo_mode.off", defaultValue: "off", comment: "Logo panic mode: disable the triple-tap gesture")
|
||||
static let disableLogoConfirmTitle = String(localized: "app_info.settings.danger.logo_disable_confirm_title", defaultValue: "turn off logo wipe?", comment: "Title asking to confirm disabling the logo triple-tap panic gesture")
|
||||
static let disableLogoConfirmAction = String(localized: "app_info.settings.danger.logo_disable_confirm_action", defaultValue: "turn off", comment: "Confirm button that disables the logo triple-tap panic gesture")
|
||||
static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe")
|
||||
static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe")
|
||||
}
|
||||
@ -569,16 +579,99 @@ struct AppInfoView: View {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
Text(Strings.Settings.panicNote)
|
||||
Text(logoShortcutPanicNote)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
settingsCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(verbatim: Strings.Settings.logoModeTitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(verbatim: Strings.Settings.logoModeSubtitle)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Picker(
|
||||
selection: Binding(
|
||||
get: { logoShortcutMode },
|
||||
set: { requestLogoShortcutMode($0) }
|
||||
)
|
||||
) {
|
||||
Text(verbatim: Strings.Settings.logoModeInstant)
|
||||
.tag(PanicWipeSettings.LogoShortcutMode.instant)
|
||||
Text(verbatim: Strings.Settings.logoModeConfirm)
|
||||
.tag(PanicWipeSettings.LogoShortcutMode.confirm)
|
||||
Text(verbatim: Strings.Settings.logoModeOff)
|
||||
.tag(PanicWipeSettings.LogoShortcutMode.off)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text(verbatim: Strings.Settings.disableLogoConfirmTitle),
|
||||
isPresented: $showDisableLogoShortcutConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(role: .destructive) {
|
||||
applyLogoShortcutMode(.off)
|
||||
} label: {
|
||||
Text(verbatim: Strings.Settings.disableLogoConfirmAction)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
/// The caption under the panic button stops being true once the gesture is
|
||||
/// off — main's copy says the logo "does the same".
|
||||
private var logoShortcutPanicNote: String {
|
||||
logoShortcutMode == .off ? Strings.Settings.panicNoteDisabled : Strings.Settings.panicNote
|
||||
}
|
||||
|
||||
/// Same problem in the privacy section: main's line says the triple-tap
|
||||
/// "asks to confirm", which is only true in confirm mode.
|
||||
private var panicPrivacyRow: AppInfoFeatureInfo {
|
||||
switch logoShortcutMode {
|
||||
case .confirm:
|
||||
return Strings.Privacy.panic
|
||||
case .instant:
|
||||
return AppInfoFeatureInfo(
|
||||
icon: "hand.raised.fill",
|
||||
resolvedTitle: String(localized: "app_info.privacy.panic.title", comment: "Title of the panic mode privacy row"),
|
||||
resolvedDescription: String(localized: "app_info.privacy.panic.description_instant", defaultValue: "triple-tap logo to wipe all data (no confirmation)", comment: "Panic mode privacy row when the logo triple-tap wipes immediately")
|
||||
)
|
||||
case .off:
|
||||
return AppInfoFeatureInfo(
|
||||
icon: "hand.raised.fill",
|
||||
resolvedTitle: String(localized: "app_info.privacy.panic.title", comment: "Title of the panic mode privacy row"),
|
||||
resolvedDescription: String(localized: "app_info.privacy.panic.description_off", defaultValue: "logo triple-tap is off — wipe from the panic button in settings", comment: "Panic mode privacy row when the logo triple-tap is turned off")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Turning the gesture off is the one transition that needs a confirm:
|
||||
/// it is the change whose failure mode is silent.
|
||||
private func requestLogoShortcutMode(_ mode: PanicWipeSettings.LogoShortcutMode) {
|
||||
guard mode == .off, logoShortcutMode != .off else {
|
||||
applyLogoShortcutMode(mode)
|
||||
return
|
||||
}
|
||||
showDisableLogoShortcutConfirm = true
|
||||
}
|
||||
|
||||
private func applyLogoShortcutMode(_ mode: PanicWipeSettings.LogoShortcutMode) {
|
||||
logoShortcutMode = mode
|
||||
PanicWipeSettings.logoShortcutMode = mode
|
||||
}
|
||||
|
||||
private func selectLanguage(_ code: String?) {
|
||||
let previous = languageOverride
|
||||
AppLanguageSettings.setOverride(code)
|
||||
@ -814,7 +907,7 @@ struct AppInfoView: View {
|
||||
|
||||
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
FeatureRow(info: panicPrivacyRow)
|
||||
}
|
||||
|
||||
// Symbols legend
|
||||
|
||||
@ -533,6 +533,20 @@ struct ContentView: View {
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
// Same reasoning as the dialog above: hosted here, not on the logo,
|
||||
// so the header chrome can't take it down with it.
|
||||
.alert(
|
||||
Text(
|
||||
String(localized: "app_info.settings.danger.logo_disabled_title", defaultValue: "logo wipe is off", comment: "Title of the alert shown when the logo triple-tap runs while the gesture is turned off")
|
||||
),
|
||||
isPresented: $appChromeModel.showPanicGestureDisabledAlert
|
||||
) {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
String(localized: "app_info.settings.danger.logo_disabled_message", defaultValue: "nothing was wiped. turn the logo gesture back on in settings, or use the panic wipe button there.", comment: "Message explaining that the logo triple-tap did nothing because the gesture is off")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var publicMessageList: some View {
|
||||
|
||||
78
bitchatTests/Services/PanicWipeSettingsTests.swift
Normal file
78
bitchatTests/Services/PanicWipeSettingsTests.swift
Normal file
@ -0,0 +1,78 @@
|
||||
//
|
||||
// PanicWipeSettingsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PanicWipeSettingsTests {
|
||||
private func isolatedDefaults() -> (suite: String, defaults: UserDefaults) {
|
||||
let suite = "PanicWipeSettingsTests.\(UUID().uuidString)"
|
||||
return (suite, UserDefaults(suiteName: suite)!)
|
||||
}
|
||||
|
||||
private func cleanup(_ suite: String) {
|
||||
UserDefaults().removePersistentDomain(forName: suite)
|
||||
}
|
||||
|
||||
/// #1509 shipped confirm-before-wipe and wrote no preference key, so every
|
||||
/// existing install lands here with nothing stored. The default has to be
|
||||
/// `.confirm` or updating silently moves those people to an instant wipe.
|
||||
@Test func defaultsToConfirmSoAnUpdateDoesNotChangeBehaviour() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
@Test func persistsInstantAndOffModes() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
PanicWipeSettings.setLogoShortcutMode(.instant, in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .instant)
|
||||
PanicWipeSettings.setLogoShortcutMode(.off, in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .off)
|
||||
PanicWipeSettings.setLogoShortcutMode(.confirm, in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
/// A wiped device must come back with the gesture armed, not carrying the
|
||||
/// seized owner's "off" setting.
|
||||
@Test func resetRestoresTheConfirmDefault() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
PanicWipeSettings.setLogoShortcutMode(.off, in: defaults)
|
||||
PanicWipeSettings.reset(in: defaults)
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
/// The two legacy keys never shipped — #1509 wrote no preference at all —
|
||||
/// so reading them would only be a way to get the default wrong.
|
||||
@Test func ignoresKeysThatNeverShipped() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
defaults.set(true, forKey: "panic.confirmLogoShortcut")
|
||||
defaults.set(false, forKey: "panic.logoShortcutEnabled")
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
/// An unparseable stored value must fall back to the safe mode, not crash
|
||||
/// or leave the gesture unarmed.
|
||||
@Test func unknownStoredValueFallsBackToConfirm() {
|
||||
let (suite, defaults) = isolatedDefaults()
|
||||
defer { cleanup(suite) }
|
||||
defaults.set("stampede", forKey: "panic.logoShortcutMode")
|
||||
#expect(PanicWipeSettings.logoShortcutMode(in: defaults) == .confirm)
|
||||
}
|
||||
|
||||
@Test func everyModeRoundTripsThroughItsRawValue() {
|
||||
for mode in PanicWipeSettings.LogoShortcutMode.allCases {
|
||||
#expect(PanicWipeSettings.LogoShortcutMode(rawValue: mode.rawValue) == mode)
|
||||
#expect(mode.id == mode.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user