mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
Merge 01f866ab43a10f08a9ce0ead8d41fa60f2f7878c into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
404a0a771d
@ -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,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
|
||||
|
||||
@ -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