Merge 01f866ab43a10f08a9ce0ead8d41fa60f2f7878c into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-09-12 02:24:36 +00:00 committed by GitHub
commit 404a0a771d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 2696 additions and 6 deletions

View File

@ -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

View 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)
}
}

View File

@ -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()

View File

@ -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,15 @@ 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 panicNoteInstant = String(localized: "app_info.settings.danger.panic_note_instant", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo wipes immediately (no confirmation).", comment: "Caption under the panic wipe button when the logo triple-tap wipes without confirmation")
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 +580,106 @@ 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 {
switch logoShortcutMode {
case .confirm:
return Strings.Settings.panicNote
case .instant:
return Strings.Settings.panicNoteInstant
case .off:
return Strings.Settings.panicNoteDisabled
}
}
/// 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 +915,7 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.ephemeral)
FeatureRow(info: Strings.Privacy.panic)
FeatureRow(info: panicPrivacyRow)
}
// Symbols legend

View File

@ -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 {

View 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)
}
}
}