mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
fix: suppress last-active persistence during panic wipe (#1064)
panicClearAllData() cleared conversation.lastActive, but the trailing selectedPrivateChatPeer=nil / activeChannel=.mesh resets route through the change-gated setters and re-persist a .mesh record via persistLastActive() (the sole write site), so the removal was a no-op and the pointer survived. Wrap the wipe in a beginPanicWipe()/finishPanicWipe() suppression window so the resets can't re-write the pointer; remove the key once at the end. The pointer now ends absent -> next launch hits the first-launch/list fallback. Adds test_panicWipeOrderLeavesNoRestoreRecord replaying the full panic order.
This commit is contained in:
parent
8428dfed66
commit
325a65ce6f
@ -351,6 +351,11 @@ final class ConversationStore: ObservableObject {
|
||||
private let storage: UserDefaults
|
||||
private let lastActiveKey = "conversation.lastActive"
|
||||
|
||||
/// True for the duration of a panic wipe. Suppresses `persistLastActive()`
|
||||
/// so the selection/channel resets the wipe performs cannot re-write the
|
||||
/// pointer we are about to remove.
|
||||
private var isPanicWiping = false
|
||||
|
||||
/// Snapshot of the persisted value read once at init, before any launch
|
||||
/// writer (e.g. `GeoChannelCoordinator` re-applying the location channel)
|
||||
/// can overwrite the key. Restore decisions read this, never the disk.
|
||||
@ -404,6 +409,9 @@ final class ConversationStore: ObservableObject {
|
||||
/// single-writer selection paths on every switch; an open DM wins over
|
||||
/// the active channel.
|
||||
private func persistLastActive() {
|
||||
// Panic wipe suppresses last-active persistence so the selection/channel
|
||||
// resets that follow it can't re-write the pointer being cleared.
|
||||
guard !isPanicWiping else { return }
|
||||
let record: LastActiveRecord
|
||||
if let peerID = selectedPrivatePeerID {
|
||||
record = LastActiveRecord(kind: .direct, peerID: peerID.id)
|
||||
@ -425,6 +433,17 @@ final class ConversationStore: ObservableObject {
|
||||
storage.removeObject(forKey: lastActiveKey)
|
||||
}
|
||||
|
||||
/// Begins a panic wipe: suppress last-active persistence so the selection/channel
|
||||
/// resets that follow cannot re-write the pointer we're about to remove.
|
||||
func beginPanicWipe() { isPanicWiping = true }
|
||||
|
||||
/// Finishes a panic wipe: remove the persisted pointer and re-enable persistence.
|
||||
/// MUST be called AFTER all selection/channel state has been reset.
|
||||
func finishPanicWipe() {
|
||||
clearPersistedLastActive()
|
||||
isPanicWiping = false
|
||||
}
|
||||
|
||||
/// Decides what to present at launch from the value persisted last
|
||||
/// session. Pure aside from reading the init snapshot: performs no
|
||||
/// selection mutation and never writes `activeChannel`, so it cannot race
|
||||
|
||||
@ -1177,9 +1177,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
conversations.clearAll()
|
||||
// Also erase the persisted last-active pointer (#1064) so a wiped
|
||||
// DM/channel cannot be restored on the next launch.
|
||||
conversations.clearPersistedLastActive()
|
||||
// Begin suppressing last-active persistence (#1064). The selection and
|
||||
// channel resets below route through the store's setters, which would
|
||||
// otherwise re-persist a `.mesh` pointer; the wipe is finished (and the
|
||||
// pointer removed once) at the very end of this method.
|
||||
conversations.beginPanicWipe()
|
||||
pendingGeohashSystemMessages.removeAll()
|
||||
|
||||
// Delete all keychain data (including Noise and Nostr keys)
|
||||
@ -1307,6 +1309,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
#endif
|
||||
}
|
||||
|
||||
// Finish the panic wipe (#1064): must run AFTER `selectedPrivateChatPeer
|
||||
// = nil` / `activeChannel = .mesh` above so no setter re-persists the
|
||||
// pointer. Removes the last-active key once and re-enables persistence,
|
||||
// leaving the key ABSENT so next launch hits the conversation-list
|
||||
// first-launch fallback.
|
||||
conversations.finishPanicWipe()
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
|
||||
|
||||
@ -112,6 +112,48 @@ final class ConversationStoreLastActiveTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func test_panicWipeOrderLeavesNoRestoreRecord() {
|
||||
// #1064 panic wipe, FULL ORDER: `clearPersistedLastActive()` alone is
|
||||
// insufficient because `panicClearAllData()` also runs
|
||||
// `selectedPrivateChatPeer = nil` and `activeChannel = .mesh` AFTER it —
|
||||
// both route through the store's setters, whose guarded `persistLastActive()`
|
||||
// re-writes a `.mesh` record on a real state change, resurrecting the
|
||||
// pointer. The begin/finish suppression window fixes that. This test
|
||||
// mirrors panicClearAllData's exact order against a single store:
|
||||
// beginPanicWipe → setSelectedPrivatePeer(nil) → setActiveChannel(.mesh) → finishPanicWipe
|
||||
// Without the fix (i.e. without begin/finish), those selection/channel
|
||||
// resets re-persist a `.mesh` record — here `setSelectedPrivatePeer(nil)`
|
||||
// is the real state change that writes it (the store's default channel
|
||||
// is already `.mesh`, so `setActiveChannel(.mesh)` is the second trigger
|
||||
// whenever panic runs from a non-mesh channel). Either way the pointer
|
||||
// survives as `.mesh` and the next launch would `.deferToChannelRestore`
|
||||
// instead of `.conversationList`.
|
||||
let storage = makeStorage()
|
||||
|
||||
// The user is in a DM: a `.direct` record is persisted and would restore.
|
||||
let session = ConversationStore(storage: storage)
|
||||
session.setSelectedPrivatePeer(peerID)
|
||||
XCTAssertEqual(
|
||||
ConversationStore(storage: storage)
|
||||
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
|
||||
.restoredDirectChat(peerID)
|
||||
)
|
||||
|
||||
// Reproduce panicClearAllData's order on the SAME store.
|
||||
session.beginPanicWipe()
|
||||
session.setSelectedPrivatePeer(nil) // re-persist trigger #1 (suppressed)
|
||||
session.setActiveChannel(.mesh) // re-persist trigger #2 (suppressed)
|
||||
session.finishPanicWipe() // removes the pointer once
|
||||
|
||||
// A fresh store on the SAME storage finds NO record and falls back to
|
||||
// the conversation list — the pointer is truly absent, not `.mesh`.
|
||||
XCTAssertEqual(
|
||||
ConversationStore(storage: storage)
|
||||
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
|
||||
.conversationList
|
||||
)
|
||||
}
|
||||
|
||||
func test_firstLaunchPresentsConversationList() {
|
||||
let storage = makeStorage()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user