Complete persistent conversation lifecycle

This commit is contained in:
callebtc 2026-07-29 03:53:29 +02:00
parent d3f01f27c6
commit c5ff1ca59a
6 changed files with 227 additions and 38 deletions

View File

@ -37,6 +37,7 @@ object AppStateStore {
@Volatile
private var conversationRepository: ConversationRepository? = null
private var privateConversationWritesSuspended = false
private val _nickname = MutableStateFlow("")
val nickname: StateFlow<String> = _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<String>) {
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)

View File

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

View File

@ -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}")

View File

@ -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<String>,
wifiAwareIdentityIDs: Set<String>,
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
}
)
}
}

View File

@ -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")
)
}
}

View File

@ -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<PersistedConversationSnapshot>()
repository.reload(snapshot::set)
runBlocking { repository.awaitPendingWrites() }
assertTrue(snapshot.get().chats.isEmpty())
assertTrue(snapshot.get().readMessageIDs.isEmpty())
assertTrue(snapshot.get().deletedMessageIDs.isEmpty())
}
}