diff --git a/app/objectbox-models/default.json b/app/objectbox-models/default.json index 8de120f..fad3965 100644 --- a/app/objectbox-models/default.json +++ b/app/objectbox-models/default.json @@ -200,10 +200,120 @@ } ], "relations": [] + }, + { + "id": "4:7887606620514562424", + "lastPropertyId": "19:4886434588165198685", + "name": "DmMessageEntity", + "properties": [ + { + "id": "1:5692570271165246660", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:7000677822556314201", + "name": "ownerPubkey", + "indexId": "9:6602733161228465634", + "type": 9, + "flags": 2048 + }, + { + "id": "3:6290484962841828502", + "name": "msgId", + "type": 9 + }, + { + "id": "4:2348777899365035302", + "name": "giftWrapId", + "type": 9 + }, + { + "id": "5:2069465575936337164", + "name": "ownerPlusGiftWrap", + "indexId": "10:3019350336205317210", + "type": 9, + "flags": 2080 + }, + { + "id": "6:2553884322257857954", + "name": "conversationKey", + "indexId": "11:2796688777436521560", + "type": 9, + "flags": 2048 + }, + { + "id": "7:8809979439595500948", + "name": "senderPubkey", + "type": 9 + }, + { + "id": "8:8843109929728616712", + "name": "content", + "type": 9 + }, + { + "id": "9:171907408823158128", + "name": "createdAt", + "type": 6 + }, + { + "id": "10:2547972877937268396", + "name": "rumorId", + "type": 9 + }, + { + "id": "11:1673398814149095965", + "name": "replyToId", + "type": 9 + }, + { + "id": "12:50500640324097366", + "name": "participantsJson", + "type": 9 + }, + { + "id": "13:8309166603960799477", + "name": "relayUrlsJson", + "type": 9 + }, + { + "id": "14:2180681062962341429", + "name": "reactionsJson", + "type": 9 + }, + { + "id": "15:4091937178395226174", + "name": "zapsJson", + "type": 9 + }, + { + "id": "16:3800181430749026871", + "name": "emojiMapJson", + "type": 9 + }, + { + "id": "17:3371948627029311978", + "name": "encryptedFileMetadataJson", + "type": 9 + }, + { + "id": "18:2402319826123827739", + "name": "debugGiftWrapJson", + "type": 9 + }, + { + "id": "19:4886434588165198685", + "name": "debugRumorJson", + "type": 9 + } + ], + "relations": [] } ], - "lastEntityId": "3:4980149860826606366", - "lastIndexId": "8:8850647956925906315", + "lastEntityId": "4:7887606620514562424", + "lastIndexId": "11:2796688777436521560", "lastRelationId": "0:0", "lastSequenceId": "0:0", "modelVersion": 5, diff --git a/app/objectbox-models/default.json.bak b/app/objectbox-models/default.json.bak index 24827be..8de120f 100644 --- a/app/objectbox-models/default.json.bak +++ b/app/objectbox-models/default.json.bak @@ -115,7 +115,7 @@ }, { "id": "3:4980149860826606366", - "lastPropertyId": "13:2695266999059672518", + "lastPropertyId": "15:5858957648151141575", "name": "GroupMetaEntity", "properties": [ { @@ -187,6 +187,16 @@ "id": "13:2695266999059672518", "name": "lastMessageAt", "type": 6 + }, + { + "id": "14:2327490369406932737", + "name": "isRestricted", + "type": 1 + }, + { + "id": "15:5858957648151141575", + "name": "isHidden", + "type": 1 } ], "relations": [] diff --git a/app/src/main/kotlin/com/wisp/app/db/DmMessageEntity.kt b/app/src/main/kotlin/com/wisp/app/db/DmMessageEntity.kt new file mode 100644 index 0000000..03b3b68 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/db/DmMessageEntity.kt @@ -0,0 +1,47 @@ +package com.wisp.app.db + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Index +import io.objectbox.annotation.Unique + +/** + * Persisted decrypted NIP-17 DM. Keyed by gift-wrap event id so re-fetched gift wraps + * can be deduped before they're sent to the remote signer for re-decryption. + * + * Reactions, zaps, file metadata, emoji map and participants are stored as JSON since + * they're nested collections and don't need to be queryable. + */ +@Entity +data class DmMessageEntity( + @Id var dbId: Long = 0, + /** Account pubkey (hex) — the local user this conversation belongs to. */ + @Index val ownerPubkey: String = "", + /** Internal DmMessage.id ("$giftWrapId:$rumorCreatedAt"). */ + val msgId: String = "", + /** kind 1059 gift wrap event id. Unique per owner — see [ownerPlusGiftWrap]. */ + val giftWrapId: String = "", + /** Composite "$ownerPubkey|$giftWrapId" for cross-account dedup. */ + @Unique val ownerPlusGiftWrap: String = "", + /** DmRepository.conversationKey — sorted, comma-joined participant pubkeys. */ + @Index val conversationKey: String = "", + val senderPubkey: String = "", + val content: String = "", + val createdAt: Long = 0L, + val rumorId: String = "", + val replyToId: String? = null, + /** Other participants (excluding owner) — JSON list. */ + val participantsJson: String = "[]", + /** Set of relay URLs that delivered this gift wrap — JSON list. */ + val relayUrlsJson: String = "[]", + /** List as JSON. */ + val reactionsJson: String = "[]", + /** List as JSON. */ + val zapsJson: String = "[]", + /** Map emoji shortcode → URL — JSON. */ + val emojiMapJson: String = "{}", + /** EncryptedFileMetadata as JSON, or null for regular text messages. */ + val encryptedFileMetadataJson: String? = null, + val debugGiftWrapJson: String? = null, + val debugRumorJson: String? = null +) diff --git a/app/src/main/kotlin/com/wisp/app/db/DmPersistence.kt b/app/src/main/kotlin/com/wisp/app/db/DmPersistence.kt new file mode 100644 index 0000000..6c701f0 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/db/DmPersistence.kt @@ -0,0 +1,209 @@ +package com.wisp.app.db + +import android.util.Log +import com.wisp.app.nostr.DmMessage +import com.wisp.app.nostr.DmReaction +import com.wisp.app.nostr.DmZap +import com.wisp.app.nostr.EncryptedMedia +import io.objectbox.Box +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * Persists decrypted NIP-17 DMs so we don't re-decrypt every gift wrap on each cold start. + * Critical for remote signer (Amber) mode where each unwrap requires two IPC round-trips. + */ +class DmPersistence { + private val box: Box = WispObjectBox.store.boxFor(DmMessageEntity::class.java) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val writeChannel = Channel>(Channel.BUFFERED) + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @Serializable + private data class ReactionDto(val authorPubkey: String, val emoji: String, val timestamp: Long, val emojiUrl: String? = null) + + @Serializable + private data class ZapDto(val zapperPubkey: String, val sats: Long, val timestamp: Long) + + @Serializable + private data class FileMetadataDto( + val fileUrl: String, + val mimeType: String, + val algorithm: String, + val keyHex: String, + val nonceHex: String, + val encryptedHash: String, + val originalHash: String, + val size: Long?, + val dimensions: String?, + val thumbhash: String?, + val blurhash: String? + ) + + init { + // Batched write-behind: collect over a short window then bulk-put + scope.launch { + val batch = mutableListOf() + for (item in writeChannel) { + batch.add(toEntity(item.first, item.second)) + while (true) { + val next = writeChannel.tryReceive().getOrNull() ?: break + batch.add(toEntity(next.first, next.second)) + } + if (batch.size < 50) { + delay(200) + while (true) { + val next = writeChannel.tryReceive().getOrNull() ?: break + batch.add(toEntity(next.first, next.second)) + } + } + try { + // Resolve existing dbIds so puts are upserts (preserve identity per ownerPlusGiftWrap) + val resolved = batch.map { entity -> + val existingId = box.query(DmMessageEntity_.ownerPlusGiftWrap.equal(entity.ownerPlusGiftWrap)) + .build().use { it.findFirst()?.dbId ?: 0L } + entity.copy(dbId = existingId) + } + box.put(resolved) + } catch (e: Exception) { + Log.w("DmPersistence", "Batch write failed: ${e.message}") + } + batch.clear() + } + } + } + + /** Queue an upsert. Safe to call repeatedly — coalesced under a 200ms window. */ + fun queueMessage(ownerPubkey: String, msg: DmMessage) { + if (ownerPubkey.isBlank() || msg.giftWrapId.isBlank()) return + writeChannel.trySend(ownerPubkey to msg) + } + + /** Load every persisted DM for an account. Caller groups by conversation. */ + fun loadAll(ownerPubkey: String): List> { + if (ownerPubkey.isBlank()) return emptyList() + return try { + val entities = box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey)) + .build().use { it.find() } + entities.mapNotNull { it.toDmMessage()?.let { msg -> it.conversationKey to msg } } + } catch (e: Exception) { + Log.w("DmPersistence", "loadAll failed: ${e.message}") + emptyList() + } + } + + /** Wipe a single account's DMs (e.g. on logout / [DmRepository.clear]). */ + fun deleteAllForOwner(ownerPubkey: String) { + if (ownerPubkey.isBlank()) return + scope.launch { + try { + box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey)) + .build().use { it.remove() } + } catch (e: Exception) { + Log.w("DmPersistence", "deleteAllForOwner failed: ${e.message}") + } + } + } + + /** Remove every conversation involving [pubkey] (mute / block flow). */ + fun deleteConversationsWithPubkey(ownerPubkey: String, pubkey: String) { + if (ownerPubkey.isBlank() || pubkey.isBlank()) return + scope.launch { + try { + val entities = box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey)) + .build().use { it.find() } + val toRemove = entities.filter { it.conversationKey.split(",").contains(pubkey) } + if (toRemove.isNotEmpty()) box.remove(toRemove) + } catch (e: Exception) { + Log.w("DmPersistence", "deleteConversationsWithPubkey failed: ${e.message}") + } + } + } + + private fun toEntity(ownerPubkey: String, msg: DmMessage): DmMessageEntity { + val reactionsJson = json.encodeToString( + ListSerializer(ReactionDto.serializer()), + msg.reactions.map { ReactionDto(it.authorPubkey, it.emoji, it.timestamp, it.emojiUrl) } + ) + val zapsJson = json.encodeToString( + ListSerializer(ZapDto.serializer()), + msg.zaps.map { ZapDto(it.zapperPubkey, it.sats, it.timestamp) } + ) + val fileJson = msg.encryptedFileMetadata?.let { + json.encodeToString(FileMetadataDto.serializer(), FileMetadataDto( + it.fileUrl, it.mimeType, it.algorithm, it.keyHex, it.nonceHex, + it.encryptedHash, it.originalHash, it.size, it.dimensions, it.thumbhash, it.blurhash + )) + } + return DmMessageEntity( + ownerPubkey = ownerPubkey, + msgId = msg.id, + giftWrapId = msg.giftWrapId, + ownerPlusGiftWrap = "$ownerPubkey|${msg.giftWrapId}", + conversationKey = inferConversationKey(ownerPubkey, msg), + senderPubkey = msg.senderPubkey, + content = msg.content, + createdAt = msg.createdAt, + rumorId = msg.rumorId, + replyToId = msg.replyToId, + participantsJson = json.encodeToString(ListSerializer(String.serializer()), msg.participants), + relayUrlsJson = json.encodeToString(ListSerializer(String.serializer()), msg.relayUrls.toList()), + reactionsJson = reactionsJson, + zapsJson = zapsJson, + emojiMapJson = json.encodeToString(MapSerializer(String.serializer(), String.serializer()), msg.emojiMap), + encryptedFileMetadataJson = fileJson, + debugGiftWrapJson = msg.debugGiftWrapJson, + debugRumorJson = msg.debugRumorJson + ) + } + + private fun inferConversationKey(ownerPubkey: String, msg: DmMessage): String = + (msg.participants + ownerPubkey).toSortedSet().joinToString(",") + + private fun DmMessageEntity.toDmMessage(): DmMessage? { + return try { + val participants: List = json.decodeFromString(ListSerializer(String.serializer()), participantsJson) + val relayUrls: List = json.decodeFromString(ListSerializer(String.serializer()), relayUrlsJson) + val reactions: List = json.decodeFromString(ListSerializer(ReactionDto.serializer()), reactionsJson) + val zaps: List = json.decodeFromString(ListSerializer(ZapDto.serializer()), zapsJson) + val emojiMap: Map = json.decodeFromString( + MapSerializer(String.serializer(), String.serializer()), emojiMapJson) + val fileMeta = encryptedFileMetadataJson?.let { + val dto: FileMetadataDto = json.decodeFromString(FileMetadataDto.serializer(), it) + EncryptedMedia.EncryptedFileMetadata( + dto.fileUrl, dto.mimeType, dto.algorithm, dto.keyHex, dto.nonceHex, + dto.encryptedHash, dto.originalHash, dto.size, dto.dimensions, dto.thumbhash, dto.blurhash + ) + } + DmMessage( + id = msgId, + senderPubkey = senderPubkey, + content = content, + createdAt = createdAt, + giftWrapId = giftWrapId, + relayUrls = relayUrls.toSet(), + rumorId = rumorId, + replyToId = replyToId, + participants = participants, + reactions = reactions.map { DmReaction(it.authorPubkey, it.emoji, it.timestamp, it.emojiUrl) }, + zaps = zaps.map { DmZap(it.zapperPubkey, it.sats, it.timestamp) }, + emojiMap = emojiMap, + encryptedFileMetadata = fileMeta, + debugGiftWrapJson = debugGiftWrapJson, + debugRumorJson = debugRumorJson + ) + } catch (e: Exception) { + Log.w("DmPersistence", "Failed to deserialize DM $giftWrapId: ${e.message}") + null + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/DmRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/DmRepository.kt index d88b08c..92d8b33 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/DmRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/DmRepository.kt @@ -3,6 +3,7 @@ package com.wisp.app.repo import android.content.Context import android.content.SharedPreferences import android.util.LruCache +import com.wisp.app.db.DmPersistence import com.wisp.app.nostr.DmConversation import com.wisp.app.nostr.DmMessage import com.wisp.app.nostr.DmReaction @@ -11,13 +12,21 @@ import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.wipe import com.wisp.app.nostr.FlatNotificationItem import com.wisp.app.nostr.NotificationType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -class DmRepository(private val context: Context? = null, pubkeyHex: String? = null) { +class DmRepository( + private val context: Context? = null, + pubkeyHex: String? = null, + private val persistence: DmPersistence? = null +) { companion object { /** @@ -83,6 +92,108 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu private val _dmNotifications = MutableStateFlow>(emptyList()) val dmNotifications: StateFlow> = _dmNotifications + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + // Seeding queries ObjectBox — dispatch off the main thread so cold-start UI isn't blocked. + ioScope.launch { seedFromPersistence() } + } + + /** + * Hydrate in-memory state from disk so we don't need to re-decrypt every gift wrap on + * each cold start. Populates [conversations], [seenGiftWraps] and [rumorIdIndex]; the + * existing dedup in [addPendingGiftWrap] then short-circuits relay-redelivered wraps + * before they hit the signer. + */ + private fun seedFromPersistence() { + val owner = myPubkey ?: return + val p = persistence ?: return + val loaded = p.loadAll(owner) + if (loaded.isEmpty()) return + var newestSeen = 0L + synchronized(lock) { + for ((convKey, msg) in loaded) { + if (seenGiftWraps.containsKey(msg.giftWrapId)) continue + seenGiftWraps[msg.giftWrapId] = msg.id + if (msg.rumorId.isNotEmpty()) { + rumorIdIndex[msg.rumorId] = Pair(convKey, msg.id) + } + if (msg.participants.isNotEmpty()) { + conversationParticipants[convKey] = msg.participants + } + val list = conversations.getOrPut(convKey) { mutableListOf() } + list.add(msg) + if (msg.createdAt > latestGiftWrapTs) latestGiftWrapTs = msg.createdAt + if (msg.createdAt > newestSeen) newestSeen = msg.createdAt + rebuildNotifItemsLocked(convKey, msg) + } + for ((_, list) in conversations) list.sortBy { it.createdAt } + val sorted = dmNotifItems.sortedByDescending { it.timestamp } + _dmNotifications.value = if (sorted.size > 200) sorted.take(200) else sorted + } + if (newestSeen > lastReadDmTimestamp) _hasUnreadDms.value = true + updateConversationList() + } + + /** + * Rebuild incoming-DM, reaction and zap notification items for a single persisted message. + * Caller must hold [lock]. Used only for hydration — the live `addMessage` path inlines + * the same logic with extra audio/haptic side-effects we want to skip on cold start. + */ + private fun rebuildNotifItemsLocked(convKey: String, msg: DmMessage) { + val owner = myPubkey ?: return + if (msg.senderPubkey != owner) { + val flatId = "dm:${msg.id}" + if (dmNotifIds.add(flatId)) { + val peerPubkey = msg.participants.firstOrNull() ?: convKey + dmNotifItems.add(FlatNotificationItem( + id = flatId, + type = NotificationType.DM, + actorPubkey = msg.senderPubkey, + referencedEventId = msg.id, + timestamp = msg.createdAt, + dmContent = msg.content, + dmPeerPubkey = peerPubkey, + dmRumorId = msg.rumorId.ifEmpty { null } + )) + } + } else { + // Reactions and zaps appear in the notifications screen only for the user's own messages. + for (reaction in msg.reactions) { + val flatId = "dmreact:${reaction.authorPubkey}:${msg.rumorId.ifEmpty { msg.id }}" + if (dmNotifIds.add(flatId)) { + val peerPubkey = if (msg.participants.size > 1) convKey + else msg.participants.firstOrNull() ?: convKey + dmNotifItems.add(FlatNotificationItem( + id = flatId, + type = NotificationType.DM_REACTION, + actorPubkey = reaction.authorPubkey, + referencedEventId = msg.rumorId.ifEmpty { msg.id }, + timestamp = reaction.timestamp, + emoji = reaction.emoji, + dmPeerPubkey = peerPubkey + )) + } + } + for (zap in msg.zaps) { + val flatId = "dmzap:${zap.zapperPubkey}:${msg.rumorId.ifEmpty { msg.id }}" + if (dmNotifIds.add(flatId)) { + val peerPubkey = if (msg.participants.size > 1) convKey + else msg.participants.firstOrNull() ?: convKey + dmNotifItems.add(FlatNotificationItem( + id = flatId, + type = NotificationType.DM_ZAP, + actorPubkey = zap.zapperPubkey, + referencedEventId = msg.rumorId.ifEmpty { msg.id }, + timestamp = zap.timestamp, + zapSats = zap.sats, + dmPeerPubkey = peerPubkey + )) + } + } + } + } + fun markDecryptingStart() { decryptingRefCount.incrementAndGet() _decrypting.value = true @@ -145,6 +256,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu messages.add(msg) messages.sortBy { it.createdAt } } + myPubkey?.let { persistence?.queueMessage(it, msg) } if (isNewIncoming) { _dmReceived.tryEmit(Unit) } @@ -159,6 +271,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu * in conversation [convKey]. */ fun addZap(convKey: String, messageId: String, zap: DmZap) { + var updated: DmMessage? = null synchronized(lock) { val messages = conversations.get(convKey) ?: return val idx = messages.indexOfFirst { it.rumorId == messageId || it.id == messageId } @@ -167,6 +280,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu // Dedupe by zapperPubkey + timestamp if (existing.zaps.any { it.zapperPubkey == zap.zapperPubkey && it.timestamp == zap.timestamp }) return messages[idx] = existing.copy(zaps = existing.zaps + zap) + updated = messages[idx] // Notify if the original message was sent by the local user val isMine = myPubkey != null && existing.senderPubkey == myPubkey @@ -189,6 +303,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu } } } + updated?.let { msg -> myPubkey?.let { persistence?.queueMessage(it, msg) } } updateConversationList() } @@ -197,6 +312,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu * If the original message was sent by the local user, also emits a DM_REACTION notification. */ fun addReaction(convKey: String, messageId: String, reaction: DmReaction) { + var updated: DmMessage? = null synchronized(lock) { val messages = conversations.get(convKey) ?: return val idx = messages.indexOfFirst { it.rumorId == messageId || it.id == messageId } @@ -207,6 +323,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu } if (alreadyReacted) return messages[idx] = existing.copy(reactions = existing.reactions + reaction) + updated = messages[idx] // Notify if the original message was sent by the local user val isMine = myPubkey != null && existing.senderPubkey == myPubkey @@ -231,6 +348,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu } } } + updated?.let { msg -> myPubkey?.let { persistence?.queueMessage(it, msg) } } updateConversationList() } @@ -308,16 +426,34 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu synchronized(lock) { // Remove all conversations that include this pubkey val keysToRemove = conversations.keys.filter { key -> key.split(",").contains(pubkey) } - keysToRemove.forEach { - conversations.remove(it) - conversationParticipants.remove(it) + keysToRemove.forEach { convKey -> + conversations.remove(convKey)?.forEach { msg -> + seenGiftWraps.remove(msg.giftWrapId) + if (msg.rumorId.isNotEmpty()) rumorIdIndex.remove(msg.rumorId) + } + conversationParticipants.remove(convKey) } conversationKeyCache.remove(pubkey) } + myPubkey?.let { persistence?.deleteConversationsWithPubkey(it, pubkey) } updateConversationList() } fun addPendingGiftWrap(event: NostrEvent, relayUrl: String) { + // Already-decrypted (and persisted) wraps must skip the queue — otherwise we'd + // re-hit the signer for every wrap on every cold start. + if (seenGiftWraps.containsKey(event.id)) { + if (relayUrl.isNotEmpty()) { + synchronized(lock) { + val msgId = seenGiftWraps[event.id] ?: return@synchronized + val convKey = conversations.entries.firstOrNull { (_, msgs) -> + msgs.any { it.id == msgId } + }?.key ?: return@synchronized + mergeRelayUrlsLocked(convKey, msgId, setOf(relayUrl)) + } + } + return + } synchronized(pendingLock) { // Dedup by event id if (pendingGiftWraps.any { it.event.id == event.id }) return @@ -351,9 +487,14 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu prefs = context?.getSharedPreferences("wisp_dm_$pubkeyHex", Context.MODE_PRIVATE) lastReadDmTimestamp = prefs?.getLong("last_read_dm", 0L) ?: 0L latestGiftWrapTs = prefs?.getLong("latest_gwrap_ts", 0L) ?: 0L + // Hydrate decrypted DMs for the new account so we don't re-decrypt on switch. + ioScope.launch { seedFromPersistence() } } fun clear() { + // Wipe persistence for the previous owner — clear() is invoked on logout / account + // switch where keeping decrypted DMs around would be wrong. + myPubkey?.let { persistence?.deleteAllForOwner(it) } synchronized(lock) { conversations.clear() conversationParticipants.clear() diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index d051ff4..0d85c33 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -174,6 +174,9 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val eventPersistence: EventPersistence? = if (WispObjectBox.isInitialized) { EventPersistence(pubkeyHex) } else null + val dmPersistence: com.wisp.app.db.DmPersistence? = if (WispObjectBox.isInitialized) { + com.wisp.app.db.DmPersistence() + } else null val eventRepo = EventRepository(profileRepo, muteRepo, relayHintStore).also { it.currentUserPubkey = pubkeyHex it.deletedEventsRepo = deletedEventsRepo @@ -183,7 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { eventRepo.contactRepo = it } val listRepo = ListRepository(app, pubkeyHex) - val dmRepo = DmRepository(app, pubkeyHex) + val dmRepo = DmRepository(app, pubkeyHex, dmPersistence) val groupRepo = GroupRepository(app, pubkeyHex) val liveStreamRepo = LiveStreamRepository() val notifRepo = NotificationRepository(app, pubkeyHex, muteRepo, eventRepo)