mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Complete persistent conversation lifecycle
This commit is contained in:
parent
d3f01f27c6
commit
c5ff1ca59a
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user