From c5ff1ca59aef99d982a49761897647c5e42a5aa6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:53:29 +0200 Subject: [PATCH] Complete persistent conversation lifecycle --- .../bitchat/android/services/AppStateStore.kt | 24 +++- .../services/ConversationRepository.kt | 20 ++- .../com/bitchat/android/ui/ChatViewModel.kt | 40 +++++- .../bitchat/android/ui/MeshPeerListSheet.kt | 120 +++++++++++++++--- .../android/services/AppStateStoreTest.kt | 29 +++++ .../services/ConversationRepositoryTest.kt | 32 +++++ 6 files changed, 227 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 6c78dec4..5691e275 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -37,6 +37,7 @@ object AppStateStore { @Volatile private var conversationRepository: ConversationRepository? = null + private var privateConversationWritesSuspended = false private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -148,6 +149,7 @@ object AppStateStore { msg: BitchatMessage, forceRead: Boolean = false ): Boolean = synchronized(this) { + if (privateConversationWritesSuspended) return@synchronized false if (seenMessageIds.contains(msg.id)) return@synchronized false seenMessageIds.add(msg.id) PrivateMessageArrivalOrder.record(msg.id) @@ -202,6 +204,7 @@ object AppStateStore { fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { synchronized(this) { + if (privateConversationWritesSuspended) return val map = _privateMessages.value.toMutableMap() var changed = false map.keys.toList().forEach { peer -> @@ -227,6 +230,7 @@ object AppStateStore { fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List) { if (keysToMerge.isEmpty()) return synchronized(this) { + if (privateConversationWritesSuspended) return val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID) .flatMap { key -> @@ -285,6 +289,7 @@ object AppStateStore { fun markPrivateMessageRead(messageID: String) { synchronized(this) { + if (privateConversationWritesSuspended) return if (messageID in _readPrivateMessageIDs.value) return _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID conversationRepository?.markRead(messageID) @@ -353,12 +358,24 @@ object AppStateStore { } } - fun clearPersistedPrivateConversations() { - synchronized(this) { - conversationRepository?.clearAll() + /** + * Atomically hides all conversations, rejects in-flight transport deliveries, then waits for + * every earlier database write and the panic wipe itself to finish. + */ + suspend fun panicClearPrivateConversations(): Boolean { + val repository = synchronized(this) { + privateConversationWritesSuspended = true _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() _selectedPrivateChatPeer.value = null + conversationRepository + } + return repository?.clearAllAndWait() ?: true + } + + fun resumePrivateConversationsAfterPanic() { + synchronized(this) { + privateConversationWritesSuspended = false } } @@ -406,6 +423,7 @@ object AppStateStore { private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { synchronized(this) { + if (privateConversationWritesSuspended) return val liveChats = _privateMessages.value val liveMessageIDs = liveChats.values.flatten().map { it.id } PrivateMessageArrivalOrder.restore(snapshot.arrivalOrder, liveMessageIDs) diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index 21ed56a4..d9513e6e 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -168,13 +168,19 @@ class ConversationRepository internal constructor( } } - fun clearAll() { - scope.launch { - try { - database.clearAll() - } catch (error: Exception) { - Log.e(TAG, "Unable to clear private conversations: ${error.message}") - } + /** + * Drains earlier writes and completes the database wipe before returning. + * + * Panic mode uses this stronger variant so identity regeneration and transport restart cannot + * race an outstanding message insert or an unfinished conversation deletion. + */ + suspend fun clearAllAndWait(): Boolean = withContext(dispatcher) { + try { + database.clearAll() + true + } catch (error: Exception) { + Log.e(TAG, "Unable to synchronously clear private conversations", error) + false } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 770b6151..b72ce65c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1119,8 +1119,22 @@ class ChatViewModel( } // MARK: - Emergency Clear - + + private var panicClearInProgress = false + fun panicClearAllData() { + if (panicClearInProgress) return + panicClearInProgress = true + viewModelScope.launch { + try { + performPanicClearAllData() + } finally { + panicClearInProgress = false + } + } + } + + private suspend fun performPanicClearAllData() { Log.w(TAG, "๐Ÿšจ PANIC MODE ACTIVATED - Clearing all sensitive data") try { com.bitchat.android.geohash.LocationChannelManager @@ -1131,9 +1145,15 @@ class ChatViewModel( // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. mediaSendingManager.clearPendingPrivateMediaConsent() - + + // Stop all message admission before wiping storage. The AppStateStore gate also rejects + // any transport callback already in flight until the fresh identity is ready. + clearAllMeshServiceData() + val conversationsCleared = + com.bitchat.android.services.AppStateStore + .panicClearPrivateConversations() + // Clear all UI managers - com.bitchat.android.services.AppStateStore.clearPersistedPrivateConversations() com.bitchat.android.services.AppStateStore.clear() messageManager.clearAllMessages() channelManager.clearAllChannels() @@ -1145,9 +1165,6 @@ class ChatViewModel( com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } - // Clear all mesh service data - clearAllMeshServiceData() - // Clear all cryptographic data clearAllCryptographicData() @@ -1179,8 +1196,17 @@ class ChatViewModel( val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - + + if (!conversationsCleared) { + // Privacy wins over availability: keep private-message admission and transports + // stopped if SQLite could not prove that the conversation history was erased. + Log.e(TAG, "๐Ÿšจ PANIC MODE INCOMPLETE - conversation database wipe failed") + return + } + // Recreate mesh service with fresh identity + com.bitchat.android.services.AppStateStore + .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() Log.w(TAG, "๐Ÿšจ PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 8ea2254c..18035967 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -707,6 +707,9 @@ private fun DirectMessagesSection( val colorScheme = MaterialTheme.colorScheme val hapticFeedback = LocalHapticFeedback.current val deleteDescription = stringResource(R.string.delete_conversation_action) + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() Column(modifier = modifier) { SheetIconSectionHeader( @@ -730,6 +733,44 @@ private fun DirectMessagesSection( if (index > 0) SheetCardDivider() val dismissState = rememberSwipeToDismissBoxState() + val favoriteTargetID = + conversation.connectedPeerID ?: conversation.conversationID + val favoriteRelationship = remember( + conversation.identityAliases, + favoritePeers, + peerFavoritedUs + ) { + conversation.identityAliases + .asSequence() + .mapNotNull { alias -> + runCatching { + FavoritesPersistenceService.shared + .getFavoriteStatus(alias) + }.getOrNull() + } + .firstOrNull() + } + val fingerprint = conversation.connectedPeerID + ?.let(peerFingerprints::get) + ?: conversation.identityAliases + .asSequence() + .mapNotNull(peerFingerprints::get) + .firstOrNull() + ?: ContactIdentityResolver + .fingerprintFromContactConversationId( + conversation.conversationID + ) + ?: favoriteRelationship?.peerNoisePublicKey?.let { + ContactIdentityResolver.fingerprintHex(it) + } + val isFavorite = if (fingerprint != null) { + fingerprint in favoritePeers + } else { + viewModel.isFavorite(favoriteTargetID) + } + val theyFavoritedUs = + (fingerprint != null && fingerprint in peerFavoritedUs) || + favoriteRelationship?.theyFavoritedUs == true LaunchedEffect(dismissState.currentValue, conversation.conversationID) { if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) @@ -779,10 +820,15 @@ private fun DirectMessagesSection( directPeerIdentityIDs = directPeerIdentityIDs, wifiAwareIdentityIDs = wifiAwareIdentityIDs, viewModel = viewModel, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, deleteDescription = deleteDescription, onClick = { onPrivateChatStart(conversation.conversationID) }, + onToggleFavorite = { + viewModel.toggleFavorite(favoriteTargetID) + }, onDeleteRequested = { onDeleteRequested(conversation) } @@ -800,8 +846,11 @@ private fun ConversationRow( directPeerIdentityIDs: Set, wifiAwareIdentityIDs: Set, viewModel: ChatViewModel, + isFavorite: Boolean, + theyFavoritedUs: Boolean, deleteDescription: String, onClick: () -> Unit, + onToggleFavorite: () -> Unit, onDeleteRequested: () -> Unit ) { val palette = LocalBitchatPalette.current @@ -930,30 +979,59 @@ private fun ConversationRow( ) } } - Text( - text = messagePreview, - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = messagePreview, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = palette.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " ยท $relativeTime", + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = palette.textTertiary, + maxLines = 1 + ) + } } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp) + UnreadBadge( + count = conversation.unreadCount, + colorScheme = colorScheme, + modifier = Modifier.padding(start = 4.dp) + ) + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center ) { - Text( - text = relativeTime, - fontFamily = BitchatFontFamily, - fontSize = 10.sp, - color = palette.textTertiary, - maxLines = 1 - ) - UnreadBadge( - count = conversation.unreadCount, - colorScheme = colorScheme + Icon( + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = stringResource( + if (isFavorite) { + R.string.cd_remove_favorite + } else { + R.string.cd_add_favorite + } + ), + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite || theyFavoritedUs) { + palette.accentOrange + } else { + palette.textTertiary + } ) } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index e095ea01..cd94e90d 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -8,16 +8,19 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import kotlinx.coroutines.runBlocking import java.util.Date class AppStateStoreTest { @Before fun setUp() { + AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() } @After fun tearDown() { + AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() } @@ -239,4 +242,30 @@ class AppStateStoreTest { assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size) assertTrue(AppStateStore.isPrivateMessageRead(message.id)) } + + @Test + fun `panic clear rejects private messages until explicitly resumed`() { + val beforePanic = BitchatMessage( + id = "before-panic", + sender = "alice", + content = "erase me", + timestamp = Date(1L), + isPrivate = true + ) + val duringPanic = beforePanic.copy(id = "during-panic") + val afterPanic = beforePanic.copy(id = "after-panic") + + assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic)) + assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) + assertTrue(AppStateStore.privateMessages.value.isEmpty()) + assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic)) + + AppStateStore.resumePrivateConversationsAfterPanic() + + assertTrue(AppStateStore.addPrivateMessage("peer-a", afterPanic)) + assertEquals( + listOf(afterPanic), + AppStateStore.privateMessages.value.getValue("peer-a") + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt index d0bba0db..c42eb9aa 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -74,4 +75,35 @@ class ConversationRepositoryTest { reloadedSnapshot.get().chats.getValue("peer-alice") ) } + + @Test + fun `panic clear drains queued writes and leaves database empty`() { + val repository = ConversationRepository( + context = context, + dispatcher = dispatcher, + databaseName = databaseName + ) + repository.upsertMessage( + conversationID = "peer-alice", + aliases = setOf("peer-alice"), + displayName = "alice", + message = BitchatMessage( + id = "queued-before-panic", + sender = "alice", + content = "must be erased", + timestamp = Date(100L), + isPrivate = true + ), + isRead = true + ) + + assertTrue(runBlocking { repository.clearAllAndWait() }) + + val snapshot = AtomicReference() + repository.reload(snapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertTrue(snapshot.get().chats.isEmpty()) + assertTrue(snapshot.get().readMessageIDs.isEmpty()) + assertTrue(snapshot.get().deletedMessageIDs.isEmpty()) + } }