From 76b1b888de95e5108248f5b639a13eda089a64fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 21:29:41 -0400 Subject: [PATCH] feat(payments): persist payment requests for offline (#1886) * feat(payments): persist payment requests for offline Store each payment request in a payment_requests table (DB 48 -> 49) as the serialized PaymentRequestMetadata, with id, chat_id_hex, version, status, ends_at and created_at columns for the merge and per-chat reads. Private-chat text stays in its encrypted form. The contract merge runs as one conditional upsert: write when there is no row, the incoming version is greater, or at an equal version the incoming copy is EXPIRED over a stored OPEN one. PaymentRequests writes every ingest and applied update through to the table and loads a chat's stored requests before its first refetch, so cards and details draw offline. Rows are erased with the rest of the account cache on account deletion. * feat(chat): persist group key envelopes for offline decryption Store each private group's key envelope as the server returned it, so GroupContentCrypto can open encrypted content offline and after a restart. The unwrapped chat key is never written. Room 49 -> 50 adds group_key_envelopes, keyed by (chat id, own public key). On an in-memory miss the stored envelope is opened with the same scheme and wrapper checks as a fetched one; a stored envelope that fails a check is dropped and the network is used. The row follows the cache: replaced on fetch, on the member's self-wrapped re-wrap and on storeNewKey; dropped on an untrusted envelope, wrong-key invite bundle, NO_ENVELOPE and ALREADY_SET. Account deletion clears it with the other chat tables. * fix(chat): drop a chat's key envelope and payment requests when it is removed --- .../flipcash/shared/chat/PaymentRequests.kt | 93 +- .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../chat/internal/RealChatCoordinator.kt | 8 + .../internal/delegates/GroupFeedDelegate.kt | 9 +- .../chat/ChatCoordinatorTeardownTest.kt | 13 + .../shared/chat/GroupFeedDelegateTest.kt | 21 +- .../chat/PaymentRequestOfflineDecryptTest.kt | 146 ++ .../shared/chat/PaymentRequestsStorageTest.kt | 156 +++ .../49.json | 1171 ++++++++++++++++ .../50.json | 1224 +++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 16 +- .../persistence/dao/GroupKeyEnvelopeDao.kt | 28 + .../app/persistence/dao/PaymentRequestDao.kt | 62 + .../entities/GroupKeyEnvelopeEntity.kt | 46 + .../entities/PaymentRequestEntity.kt | 61 + .../dao/GroupKeyEnvelopeDaoTest.kt | 87 ++ .../persistence/dao/PaymentRequestDaoTest.kt | 147 ++ .../sources/ChatMetadataDataSource.kt | 21 + .../sources/GroupKeyEnvelopeDataSource.kt | 57 + .../sources/PaymentRequestDataSource.kt | 69 + .../mediator/ChatFeedRemoteMediator.kt | 4 +- .../sources/ChatRemovalDataTest.kt | 121 ++ .../mediator/ChatFeedRemoteMediatorTest.kt | 8 +- .../services/chat/GroupKeyEnvelopeStore.kt | 20 + .../services/chat/GroupKeyEnvelopes.kt | 115 +- .../models/paymentrequest/PaymentRequest.kt | 3 + .../paymentrequest/PaymentRequestStorage.kt | 100 ++ .../services/chat/GroupKeyEnvelopesTest.kt | 231 +++- .../PaymentRequestStorageTest.kt | 104 ++ .../chat/InMemoryGroupKeyEnvelopeStore.kt | 23 + 30 files changed, 4136 insertions(+), 35 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestOfflineDecryptTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsStorageTest.kt create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/49.json create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/50.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt create mode 100644 services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt index 274a99ad28..6efb2e5d10 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat +import com.flipcash.app.persistence.sources.PaymentRequestDataSource import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.controllers.PaymentRequestController @@ -17,11 +18,14 @@ import com.flipcash.services.models.paymentrequest.PaymentRequestText import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.paymentrequest.newerOf import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -34,12 +38,17 @@ import javax.inject.Singleton * its text is opened once, then [merge] keeps the newer copy. The chat's event stream also announces * changes, best effort, which [onUpdates] applies by version (see [applyUpdate]); polling stays as * reconciliation for whatever it misses. + * Every change to the held map is also written to [store] (see [persist]), which keeps the newer copy + * by the contract's rule, and a chat's stored requests are loaded into the map before its first + * refetch ([hydrate]), so cards draw offline and after a restart. Only requests are stored: + * [missing] and the in-flight fetches are per-process and start empty. * A request's `payment_intent_id` never enters the store; [fetch] hands it to the caller. */ @Singleton class PaymentRequests @Inject constructor( private val controller: PaymentRequestController, private val crypto: GroupContentCrypto, + private val store: PaymentRequestDataSource, ) : PaymentRequestUpdateSink { private val requests = MutableStateFlow>(emptyMap()) @@ -52,14 +61,23 @@ class PaymentRequests @Inject constructor( private val inFlight = MutableStateFlow>(emptySet()) - /** The held requests made in [chatId], in no particular order. */ - fun inChat(chatId: ChatId): Flow> = - requests.map { all -> all.values.filter { it.chatId == chatId } }.distinctUntilChanged() + private val hydrated = MutableStateFlow>(emptySet()) - fun request(id: ID): Flow = requests.map { it[id] }.distinctUntilChanged() + /** The held requests made in [chatId], in no particular order; its stored ones are loaded first. */ + fun inChat(chatId: ChatId): Flow> = flow { + hydrate(chatId) + emitAll(requests.map { all -> all.values.filter { it.chatId == chatId } }.distinctUntilChanged()) + } + + /** The held request, loaded from storage first when the map has none. */ + fun request(id: ID): Flow = flow { + if (id !in requests.value) hydrate(id) + emitAll(requests.map { it[id] }.distinctUntilChanged()) + } /** Refetches [chatId]'s open requests (first 100) and recently resolved ones (first 20). */ suspend fun loadChat(chatId: ChatId): Result = runCatching { + hydrate(chatId) val open = controller.getChatPaymentRequests(chatId, PaymentRequestSection.OPEN, QueryOptions(limit = OPEN_PAGE)) .getOrThrow() val resolved = controller.getChatPaymentRequests(chatId, PaymentRequestSection.RESOLVED, QueryOptions(limit = RESOLVED_PAGE)) @@ -116,6 +134,7 @@ class PaymentRequests @Inject constructor( suspend fun ingest(incoming: List) { val opened = incoming.map { open(it) } requests.update { held -> opened.fold(held, ::merge) } + persist(opened) } /** @@ -131,11 +150,16 @@ class PaymentRequests @Inject constructor( is PaymentRequestUpdate.Kind.Created -> ingest(listOf(kind.paymentRequest)) is PaymentRequestUpdate.Kind.Updates, PaymentRequestUpdate.Kind.Unknown -> { var stale = false + var applied: PaymentRequest? = null requests.update { held -> val outcome = applyUpdate(held, update) stale = outcome.refetch + applied = outcome.requests[update.paymentRequestId] + ?.takeIf { it !== held[update.paymentRequestId] } outcome.requests } + // The whole request at its new version, which the store keeps if it is newer. + applied?.let { persist(listOf(it)) } if (stale) refetch += update.paymentRequestId } } @@ -155,9 +179,63 @@ class PaymentRequests @Inject constructor( } } + /** Drops what the session holds; the stored requests stay for the account's next session. */ fun clear() { requests.value = emptyMap() _missing.value = emptySet() + hydrated.value = emptySet() + } + + /** + * Drops [chatId]'s requests from what the session holds, for a chat the viewer has left. The + * stored rows go with the membership change (see `ChatMetadataDataSource.removeFromChat`); this + * is the in-memory half, and un-marks the chat as hydrated so a rejoin loads afresh. + */ + fun forget(chatId: ChatId) { + val gone = requests.value.filterValues { it.chatId == chatId }.keys + requests.update { held -> held - gone } + _missing.update { it - gone } + hydrated.update { it - chatId } + } + + /** Erases the stored requests, with the rest of the account's cache. */ + suspend fun clearStored() = store.clear() + + /** + * Loads [chatId]'s stored requests into the map, once per session. They merge like fetched + * copies, so a newer one already held stays, and they are not written back. A store that fails + * leaves the map as it was. + */ + private suspend fun hydrate(chatId: ChatId) { + var first = false + hydrated.update { held -> first = chatId !in held; held.plusElement(chatId) } + if (!first) return + val stored = storeCatching { store.inChat(chatId) } ?: return + load(stored) + } + + private suspend fun hydrate(id: ID) { + val stored = storeCatching { store.find(id) } ?: return + load(listOf(stored)) + } + + private suspend fun load(stored: List) { + val opened = stored.map { open(it) } + requests.update { held -> opened.fold(held, ::merge) } + } + + private suspend fun persist(written: List) { + storeCatching { store.merge(written) } + } + + // The store is a cache of what the network says: a failure to read or write it must not fail + // the fetch or the update it rode along with. + private suspend fun storeCatching(block: suspend () -> T): T? = try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null } private suspend fun ingestOne(request: PaymentRequest): PaymentRequest { @@ -167,11 +245,12 @@ class PaymentRequests @Inject constructor( private suspend fun open(request: PaymentRequest): PaymentRequest { val kind = request.kind as? PaymentRequest.Kind.GroupSplit ?: return request - if (kind.split.text !is PaymentRequestText.Encrypted) return request + val sealed = kind.split.text as? PaymentRequestText.Encrypted ?: return request // A key that can't be fetched yet leaves the text sealed; a later fetch opens it. - val text = crypto.openPaymentRequestText(kind.chatId, request.creator, kind.split.text) + val text = crypto.openPaymentRequestText(kind.chatId, request.creator, sealed) .getOrElse { return request } - return request.copy(kind = kind.copy(split = kind.split.copy(text = text))) + // The sealed form rides along so storage keeps the text as received. + return request.copy(kind = kind.copy(split = kind.split.copy(text = text, sealedText = sealed))) } /** [requests] after an update, and whether the request must be refetched to reconcile it. */ diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 0e6efa50e9..9b7982e77d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -28,6 +28,8 @@ import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.flipcash.shared.chat.internal.delegates.MediaSendDelegate import com.flipcash.shared.chat.media.ChatMediaSending +import com.flipcash.app.persistence.sources.GroupKeyEnvelopeDataSource +import com.flipcash.services.chat.GroupKeyEnvelopeStore import com.getcode.opencode.providers.SessionListener import dagger.Binds import dagger.Module @@ -109,6 +111,11 @@ abstract class ChatModule { impl: PaymentRequests ): PaymentRequestUpdateSink + @Binds + internal abstract fun bindGroupKeyEnvelopeStore( + impl: GroupKeyEnvelopeDataSource + ): GroupKeyEnvelopeStore + @Binds internal abstract fun bindReactionAlertSink( impl: ReactionAlertController diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 735ede2f93..42dc4fc088 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -8,6 +8,7 @@ import android.os.Handler import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.chat.GroupKeyEnvelopeStore import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore @@ -109,6 +110,7 @@ class RealChatCoordinator @Inject constructor( private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None, private val paymentRequests: PaymentRequests? = null, private val hostInvites: HostInvites? = null, + private val groupKeyEnvelopes: GroupKeyEnvelopeStore? = null, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -345,6 +347,9 @@ class RealChatCoordinator @Inject constructor( // record is left behind, harmlessly, because the chat is no longer a member chat and // so is in neither feed. archiveStore.unarchive(chatId) + // The stored requests went with the membership change; this is the in-memory copy. + // A removal or remote leave has no path here, and its cards stay until the session ends. + paymentRequests?.forget(chatId) } /** @@ -451,6 +456,9 @@ class RealChatCoordinator @Inject constructor( // the same file — and account deletion is the one path that is meant to erase them. draftStore.clearAll() archiveStore.clearAll() + paymentRequests?.clearStored() + // Wrapped chat keys, stored per account like the rows above. + groupKeyEnvelopes?.clear() trace(tag = TAG, message = "cache cleared", type = TraceType.Process) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index cb1c1ce47a..6bbc30e1bc 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -200,7 +200,12 @@ class GroupFeedDelegate @Inject constructor( override suspend fun leave(chatId: ChatId): Result { metadataDataSource.setMembership(chatId, isMember = false) return chatController.leaveChat(chatId) - .onSuccess { metadataDataSource.clearViewerState(chatId) } + .onSuccess { + metadataDataSource.clearViewerState(chatId) + // On success only, like the viewer state: a failed leave keeps the user in the + // chat, which still needs its key and requests. + metadataDataSource.removeFromChat(chatId) + } .onFailure { trace(tag = TAG, message = "Leave failed for $chatId", type = TraceType.Error) metadataDataSource.setMembership(chatId, isMember = true) @@ -247,7 +252,7 @@ class GroupFeedDelegate @Inject constructor( is RosterChange.MemberLeft -> { if (change.userId != selfId) continue - metadataDataSource.setMembership(chatId, isMember = false) + metadataDataSource.removeFromChat(chatId) // Same reason as [leave]: the mute is gone server-side the moment you are out // of the chat, whether you left from this device or another one. metadataDataSource.clearViewerState(chatId) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 0836686397..883c5c99e0 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -56,6 +56,7 @@ class ChatCoordinatorTeardownTest { private lateinit var archiveStore: ChatArchiveStore private lateinit var groupFeedDelegate: GroupFeedDelegate private lateinit var hostInvites: HostInvites + private lateinit var groupKeyEnvelopes: com.flipcash.services.chat.GroupKeyEnvelopeStore private lateinit var coordinator: RealChatCoordinator @Before @@ -78,6 +79,7 @@ class ChatCoordinatorTeardownTest { archiveStore = mockk(relaxed = true) groupFeedDelegate = mockk(relaxed = true) hostInvites = mockk(relaxed = true) + groupKeyEnvelopes = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -130,6 +132,7 @@ class ChatCoordinatorTeardownTest { reactionsDelegate = mockk(relaxed = true), archiveStore = archiveStore, hostInvites = hostInvites, + groupKeyEnvelopes = groupKeyEnvelopes, ) } @@ -189,6 +192,16 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 1) { archiveStore.clearAll() } } + /** Wrapped chat keys live in the per-account database, so erasure wipes them and teardown keeps them. */ + @Test + fun `clearCache drops stored key envelopes, teardown keeps them`() = runTest { + coordinator.teardown() + coVerify(exactly = 0) { groupKeyEnvelopes.clear() } + + coordinator.clearCache() + coVerify(exactly = 1) { groupKeyEnvelopes.clear() } + } + @Test fun `a successful leave unarchives the chat`() = runTest { val chatId = ChatId("aabbccdd") diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt index 279a762741..1af6464a9e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt @@ -247,6 +247,24 @@ class GroupFeedDelegateTest { coVerify(exactly = 0) { metadataDataSource.setMembership(chatId, isMember = true) } } + @Test + fun `a successful leave drops the chat's key envelope and payment requests`() = runTest { + coEvery { controller.leaveChat(chatId) } returns Result.success(Unit) + + subject.leave(chatId) + + coVerify(exactly = 1) { metadataDataSource.removeFromChat(chatId) } + } + + @Test + fun `a failed leave keeps the chat's key envelope and payment requests`() = runTest { + coEvery { controller.leaveChat(chatId) } returns Result.failure(Throwable("unreachable")) + + subject.leave(chatId) + + coVerify(exactly = 0) { metadataDataSource.removeFromChat(any()) } + } + @Test fun `a failed leave puts the chat back`() = runTest { coEvery { controller.leaveChat(chatId) } returns Result.failure(Throwable("unreachable")) @@ -320,7 +338,7 @@ class GroupFeedDelegateTest { subject.applyRosterChanges(chatId, listOf(change)) - coVerify { metadataDataSource.setMembership(chatId, isMember = false) } + coVerify { metadataDataSource.removeFromChat(chatId) } } @Test @@ -333,6 +351,7 @@ class GroupFeedDelegateTest { subject.applyRosterChanges(chatId, listOf(change)) coVerify(exactly = 0) { metadataDataSource.setMembership(any(), any()) } + coVerify(exactly = 0) { metadataDataSource.removeFromChat(any()) } coVerify { rosterStateHolder.apply(chatId, listOf(change)) } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestOfflineDecryptTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestOfflineDecryptTest.kt new file mode 100644 index 0000000000..f7f9ffee05 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestOfflineDecryptTest.kt @@ -0,0 +1,146 @@ +package com.flipcash.shared.chat + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.GroupKeyEnvelopeDataSource +import com.flipcash.app.persistence.sources.PaymentRequestDataSource +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.FakeInviteCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.chat.WrongKeyChats +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.PaymentRequestController +import com.flipcash.services.models.GetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.StoredKeyEnvelope +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +/** + * A private group's payment request text opens offline after a restart: the stored request still + * holds the sealed text, and the stored key envelope opens the chat key with no network. Real + * [GroupContentCrypto] and [GroupKeyEnvelopes] over the real data sources. + */ +@RunWith(RobolectricTestRunner::class) +class PaymentRequestOfflineDecryptTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val requestId = List(16) { 9 } + private val creator: ID = List(16) { 1 } + private val ownKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val groupKey = ByteArray(32) { 5 } + private val keySource = object : ChatKeySource { + override fun ownKeyPair() = ownKeys + override fun ownUserId() = creator + override suspend fun peerPublicKey(userId: ID): Result = Result.failure(IOException("offline")) + } + private val paymentStore = PaymentRequestDataSource() + private val envelopeStore = GroupKeyEnvelopeDataSource() + + // PaymentRequestText { title = 1, description = 2 }, encoded by hand. + private fun textProto(title: String, description: String): ByteArray = + byteArrayOf(0x0A, title.length.toByte()) + title.toByteArray() + + byteArrayOf(0x12, description.length.toByte()) + description.toByteArray() + + private val sealedText = FakeChatCipher.encryptPaymentRequestText( + textProto("Dinner", "Tacos"), groupKey, chatId.bytes, creator.toByteArray(), + ).let { PaymentRequestText.Encrypted(PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305, it.nonce, it.ciphertext) } + + @Before + fun setUp() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "payment-request-offline-decrypt-test") + runBlocking { paymentStore.clear(); envelopeStore.clear() } + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + private fun request() = PaymentRequest( + id = requestId, + creator = creator, + createdAt = Instant.fromEpochSeconds(0), + status = PaymentRequestStatus.Open, + closedAt = null, + version = 1, + kind = PaymentRequest.Kind.GroupSplit( + chatId = chatId, + split = Split( + text = sealedText, + currency = CurrencyCode.USD, + endsAt = Instant.fromEpochSeconds(4_000_000_000), + shares = listOf(Share(List(16) { 2 }, Fiat(quarks = 10_000_000, currencyCode = CurrencyCode.USD), null)), + ), + ), + ) + + /** One app launch: its own in-memory caches over the shared databases. */ + private fun launch(online: Boolean): PaymentRequests { + val chats = mockk() + if (online) { + val self = FakeChatCipher.wrapGroupKey(ownKeys, ownKeys.publicKey, chatId.bytes, groupKey) + coEvery { chats.getKeyEnvelope(chatId) } returns Result.success( + StoredKeyEnvelope(KeyEnvelope(KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305, self.nonce, self.ciphertext), wrappedBy = null), + ) + } else { + coEvery { chats.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))) + } + val keys = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), keySource, chats, WrongKeyChats(), envelopeStore) + val crypto = GroupContentCrypto(FakeChatCipher, keys) + val controller = mockk() + coEvery { controller.getChatPaymentRequests(any(), any(), any()) } returns + if (online) Result.success(PaymentRequestPage(listOf(request()), null, false)) + else Result.failure(IOException("offline")) + return PaymentRequests(controller, crypto, paymentStore) + } + + @Test + fun `encrypted text opens offline after a restart`() = runBlocking { + launch(online = true).loadChat(chatId).getOrThrow() + + val restarted = launch(online = false) + val shown = restarted.inChat(chatId).first().single() + + assertEquals(PaymentRequestText.Plain("Dinner", "Tacos"), shown.split?.text) + assertIs(paymentStore.find(requestId)?.split?.text) + Unit + } + + @Test + fun `without a stored envelope the text waits offline`() = runBlocking { + launch(online = true).loadChat(chatId).getOrThrow() + envelopeStore.clear() + + val shown = launch(online = false).inChat(chatId).first().single() + + assertIs(shown.split?.text) + Unit + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsStorageTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsStorageTest.kt new file mode 100644 index 0000000000..46ace31970 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsStorageTest.kt @@ -0,0 +1,156 @@ +package com.flipcash.shared.chat + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.PaymentRequestDataSource +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.controllers.PaymentRequestController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaidShare +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Requests are written through to the database and read back before the network answers, so + * a restarted app (a second [PaymentRequests] over the same database) still has them offline. + */ +@RunWith(RobolectricTestRunner::class) +class PaymentRequestsStorageTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val id = List(16) { 9 } + private val alice = List(16) { 2 } + private val sealed = PaymentRequestText.Encrypted( + PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305, ByteArray(24) { 7 }, ByteArray(40) { 9 }, + ) + private val store = PaymentRequestDataSource() + private val crypto = mockk() + + @Before + fun setUp() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "payment-requests-storage-test") + runBlocking { store.clear() } + coEvery { crypto.openPaymentRequestText(any(), any(), any()) } returns + Result.success(PaymentRequestText.Plain("Dinner", "Tacos")) + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + private fun request(version: Long, status: PaymentRequestStatus = PaymentRequestStatus.Open) = PaymentRequest( + id = id, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = status, + closedAt = null, + version = version, + kind = PaymentRequest.Kind.GroupSplit( + chatId = chatId, + split = Split( + text = sealed, + currency = CurrencyCode.USD, + endsAt = Instant.fromEpochSeconds(4_000_000_000), + shares = listOf(Share(alice, Fiat(quarks = 10_000_000, currencyCode = CurrencyCode.USD), null)), + ), + ), + ) + + private fun requests(network: Result): PaymentRequests { + val controller = mockk() + coEvery { controller.getChatPaymentRequests(any(), any(), any()) } returns network + return PaymentRequests(controller, crypto, store) + } + + private fun page(vararg requests: PaymentRequest) = Result.success(PaymentRequestPage(requests.toList(), null, false)) + + @Test + fun `a failed refetch leaves the stored requests showing after a restart`() = runBlocking { + requests(page(request(version = 3))).loadChat(chatId).getOrThrow() + + val restarted = requests(Result.failure(RuntimeException("offline"))) + assertTrue(restarted.loadChat(chatId).isFailure) + + val shown = restarted.inChat(chatId).first().single() + assertEquals(3, shown.version) + assertEquals(PaymentRequestText.Plain("Dinner", "Tacos"), shown.split?.text) + assertEquals(shown, restarted.request(id).first()) + } + + @Test + fun `stored text stays sealed`() = runBlocking { + requests(page(request(version = 3))).loadChat(chatId).getOrThrow() + + val stored = store.find(id)!! + assertIs(stored.split?.text) + Unit + } + + @Test + fun `an applied update is written through at its new version`() = runBlocking { + val live = requests(page(request(version = 3))) + live.loadChat(chatId).getOrThrow() + val paid = PaidShare(Instant.fromEpochSeconds(500), PaidShare.How.Payment) + + live.onUpdates( + listOf(PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Updates(4, listOf(PaymentRequestUpdate.Change.SharePaid(alice, paid))))), + ) + + val stored = store.find(id)!! + assertEquals(4, stored.version) + assertEquals(paid, stored.split?.shareOf(alice)?.paid) + assertIs(stored.split?.text) + Unit + } + + @Test + fun `an ignored stale update writes nothing`() = runBlocking { + val live = requests(page(request(version = 3))) + live.loadChat(chatId).getOrThrow() + + live.onUpdates(listOf(PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Updates(2, emptyList())))) + + assertEquals(3, store.find(id)!!.version) + } + + @Test + fun `a stale fetch does not overwrite a newer stored request`() = runBlocking { + requests(page(request(version = 5))).loadChat(chatId).getOrThrow() + + requests(page(request(version = 4))).loadChat(chatId).getOrThrow() + + assertEquals(5, store.find(id)!!.version) + } + + @Test + fun `clearing the store removes the requests`() = runBlocking { + val live = requests(page(request(version = 3))) + live.loadChat(chatId).getOrThrow() + + live.clearStored() + + assertEquals(null, store.find(id)) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/49.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/49.json new file mode 100644 index 0000000000..f1b998a75e --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/49.json @@ -0,0 +1,1171 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "be08d3732dbe316565fde7ec3767e822", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be08d3732dbe316565fde7ec3767e822')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/50.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/50.json new file mode 100644 index 0000000000..96bc8aff60 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/50.json @@ -0,0 +1,1224 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "de798dd1c8387a241e17972514847d4a", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + }, + { + "tableName": "group_key_envelopes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `own_pk_hex` TEXT NOT NULL, `scheme` INTEGER NOT NULL, `nonce` BLOB NOT NULL, `ciphertext` BLOB NOT NULL, `wrapped_by` BLOB, `wrapped_by_public_key` BLOB, PRIMARY KEY(`chat_id_hex`, `own_pk_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownPkHex", + "columnName": "own_pk_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheme", + "columnName": "scheme", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "wrappedBy", + "columnName": "wrapped_by", + "affinity": "BLOB" + }, + { + "fieldPath": "wrappedByPublicKey", + "columnName": "wrapped_by_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "own_pk_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'de798dd1c8387a241e17972514847d4a')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index d9e1127e81..abd5ce36e3 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -28,6 +28,8 @@ import com.flipcash.app.persistence.dao.ContactDao import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao import com.flipcash.app.persistence.dao.LinkPreviewDao import com.flipcash.app.persistence.dao.MessageDao +import com.flipcash.app.persistence.dao.GroupKeyEnvelopeDao +import com.flipcash.app.persistence.dao.PaymentRequestDao import com.flipcash.app.persistence.dao.PendingMediaDao import com.flipcash.app.persistence.dao.TokenDao import com.flipcash.app.persistence.dao.UserProfileDao @@ -42,6 +44,8 @@ import com.flipcash.app.persistence.entities.ContactSyncStateEntity import com.flipcash.app.persistence.entities.CurrencyCreatorDraftEntity import com.flipcash.app.persistence.entities.LinkPreviewEntity import com.flipcash.app.persistence.entities.MessageEntity +import com.flipcash.app.persistence.entities.GroupKeyEnvelopeEntity +import com.flipcash.app.persistence.entities.PaymentRequestEntity import com.flipcash.app.persistence.entities.PendingMediaEntity import com.flipcash.app.persistence.entities.SocialLinkEntity import com.flipcash.app.persistence.entities.TokenEntity @@ -70,6 +74,8 @@ import com.getcode.utils.subByteArray UserProfileEntity::class, LinkPreviewEntity::class, PendingMediaEntity::class, + PaymentRequestEntity::class, + GroupKeyEnvelopeEntity::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class), @@ -132,8 +138,14 @@ import com.getcode.utils.subByteArray AutoMigration(from = 46, to = 47), // chat_metadata pin summary: pin_count, pin_version (default 0), latest_pin (nullable blob) AutoMigration(from = 47, to = 48), + // The payment_requests table. An AutoMigration rather than the destructive fallback, like + // the other tables here, so a version bump never drops the stored requests. + AutoMigration(from = 48, to = 49), // payment_requests table + // The group_key_envelopes table: wrapped chat keys, so a private group opens offline. An + // AutoMigration for the same reason as payment_requests. + AutoMigration(from = 49, to = 50), // group_key_envelopes table ], - version = 48, + version = 50, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -151,6 +163,8 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun userProfileDao(): UserProfileDao abstract fun linkPreviewDao(): LinkPreviewDao abstract fun pendingMediaDao(): PendingMediaDao + abstract fun paymentRequestDao(): PaymentRequestDao + abstract fun groupKeyEnvelopeDao(): GroupKeyEnvelopeDao class Migration1To2 : Migration(1, 2), AutoMigrationSpec { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt new file mode 100644 index 0000000000..5421dfbc34 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.flipcash.app.persistence.entities.GroupKeyEnvelopeEntity + +@Dao +interface GroupKeyEnvelopeDao { + + /** Stores [entity], replacing the row for the same chat and own key. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: GroupKeyEnvelopeEntity) + + @Query("SELECT * FROM group_key_envelopes WHERE chat_id_hex = :chatIdHex AND own_pk_hex = :ownPkHex") + suspend fun find(chatIdHex: String, ownPkHex: String): GroupKeyEnvelopeEntity? + + @Query("DELETE FROM group_key_envelopes WHERE chat_id_hex = :chatIdHex AND own_pk_hex = :ownPkHex") + suspend fun delete(chatIdHex: String, ownPkHex: String) + + /** Removes every envelope stored for [chatIdHex], whichever own key it was wrapped for. */ + @Query("DELETE FROM group_key_envelopes WHERE chat_id_hex = :chatIdHex") + suspend fun deleteForChat(chatIdHex: String) + + @Query("DELETE FROM group_key_envelopes") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt new file mode 100644 index 0000000000..2ddb7282f6 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Query +import com.flipcash.app.persistence.entities.PaymentRequestEntity + +@Dao +interface PaymentRequestDao { + + /** + * Stores a request unless the stored copy is newer, by the `paymentrequest.v1` merge rule: + * written when there is no row, when [version] is greater than the stored one, or at an equal + * version when the incoming copy is EXPIRED and the stored one is OPEN (expiry moves no + * version). A lower version, or an equal one that is anything else, leaves the row alone. The + * whole row is replaced, as the contract has clients replace the message whole. + * + * One statement, so the compare and the write are atomic and a stale write never overwrites a + * newer row however writers interleave. + */ + @Query( + "INSERT INTO payment_requests " + + "(id_hex, chat_id_hex, version, status, ends_at, created_at, request) " + + "VALUES (:idHex, :chatIdHex, :version, :status, :endsAtEpochMs, :createdAtEpochMs, :request) " + + "ON CONFLICT(id_hex) DO UPDATE SET " + + "chat_id_hex = excluded.chat_id_hex, version = excluded.version, status = excluded.status, " + + "ends_at = excluded.ends_at, created_at = excluded.created_at, request = excluded.request " + + "WHERE excluded.version > payment_requests.version " + + "OR (excluded.version = payment_requests.version " + + "AND excluded.status = 'EXPIRED' AND payment_requests.status = 'OPEN')" + ) + suspend fun mergeIfNewer( + idHex: String, + chatIdHex: String, + version: Long, + status: String, + endsAtEpochMs: Long?, + createdAtEpochMs: Long, + request: ByteArray, + ) + + suspend fun mergeIfNewer(entity: PaymentRequestEntity) = mergeIfNewer( + idHex = entity.idHex, + chatIdHex = entity.chatIdHex, + version = entity.version, + status = entity.status, + endsAtEpochMs = entity.endsAtEpochMs, + createdAtEpochMs = entity.createdAtEpochMs, + request = entity.request, + ) + + @Query("SELECT * FROM payment_requests WHERE id_hex = :idHex") + suspend fun find(idHex: String): PaymentRequestEntity? + + @Query("SELECT * FROM payment_requests WHERE chat_id_hex = :chatIdHex") + suspend fun inChat(chatIdHex: String): List + + @Query("DELETE FROM payment_requests WHERE chat_id_hex = :chatIdHex") + suspend fun deleteForChat(chatIdHex: String) + + @Query("DELETE FROM payment_requests") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt new file mode 100644 index 0000000000..df5c4e1ad4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt @@ -0,0 +1,46 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * A private group's chat key envelope as the server returned it for the viewer, one row per + * (chat, own public key). The envelope is still wrapped: the chat key itself is never written + * here, so the file holds nothing that opens a chat without the viewer's private key. + * + * The own public key is part of the key so an envelope wrapped for one account is never opened + * as another's. + */ +@Entity( + tableName = "group_key_envelopes", + primaryKeys = ["chat_id_hex", "own_pk_hex"], +) +data class GroupKeyEnvelopeEntity( + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "own_pk_hex") + val ownPkHex: String, + // The raw `KeyEnvelope.Scheme` number, so an unknown scheme round-trips and is refused on read. + @ColumnInfo(name = "scheme") + val scheme: Int, + @ColumnInfo(name = "nonce", typeAffinity = ColumnInfo.BLOB) + val nonce: ByteArray, + @ColumnInfo(name = "ciphertext", typeAffinity = ColumnInfo.BLOB) + val ciphertext: ByteArray, + // The user whose key wrapped it; null when the server did not say (read as the viewer's own). + @ColumnInfo(name = "wrapped_by", typeAffinity = ColumnInfo.BLOB) + val wrappedBy: ByteArray?, + // The key the server reported for the wrapper, a hint only. + @ColumnInfo(name = "wrapped_by_public_key", typeAffinity = ColumnInfo.BLOB) + val wrappedByPublicKey: ByteArray?, +) { + // Arrays are compared by content, unlike a data class's default. + override fun equals(other: Any?): Boolean = + this === other || (other is GroupKeyEnvelopeEntity && + chatIdHex == other.chatIdHex && ownPkHex == other.ownPkHex && scheme == other.scheme && + nonce.contentEquals(other.nonce) && ciphertext.contentEquals(other.ciphertext) && + wrappedBy.contentEquals(other.wrappedBy) && + wrappedByPublicKey.contentEquals(other.wrappedByPublicKey)) + + override fun hashCode(): Int = 31 * chatIdHex.hashCode() + ownPkHex.hashCode() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt new file mode 100644 index 0000000000..e74c5f0c47 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * A payment request as the viewer last received it, one row per request. + * + * [request] is the serialized `paymentrequest.v1.PaymentRequestMetadata`, text still in its + * encrypted form where the chat is private (the same rule `chat_messages` follows: the database + * is not encrypted, so only what the server sent in the clear is stored in the clear). The other + * columns are read for queries and for the merge in `PaymentRequestDao.mergeIfNewer`; the proto + * is the source of truth for everything else, so a new field never needs a migration. + * + * Added by an `AutoMigration` rather than left to `fallbackToDestructiveMigration()`, like the + * other tables here, so a version bump never drops the file. + */ +@Entity( + tableName = "payment_requests", + indices = [Index(value = ["chat_id_hex"])], +) +data class PaymentRequestEntity( + @PrimaryKey + @ColumnInfo(name = "id_hex") + val idHex: String, + // Empty for a request of a kind the app doesn't know, which has no chat to list it under. + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + // Compared by the merge: the greater version wins. + @ColumnInfo(name = "version") + val version: Long, + // STATUS_* below, compared by the merge at an equal version. + @ColumnInfo(name = "status") + val status: String, + // Epoch millis of the split's deadline; null for a request with no split. + @ColumnInfo(name = "ends_at") + val endsAtEpochMs: Long?, + @ColumnInfo(name = "created_at") + val createdAtEpochMs: Long, + @ColumnInfo(name = "request", typeAffinity = ColumnInfo.BLOB) + val request: ByteArray, +) { + // The array is compared by content, unlike a data class's default. + override fun equals(other: Any?): Boolean = + this === other || (other is PaymentRequestEntity && + idHex == other.idHex && chatIdHex == other.chatIdHex && version == other.version && + status == other.status && endsAtEpochMs == other.endsAtEpochMs && + createdAtEpochMs == other.createdAtEpochMs && request.contentEquals(other.request)) + + override fun hashCode(): Int = 31 * idHex.hashCode() + version.hashCode() + + companion object { + const val STATUS_OPEN = "OPEN" + const val STATUS_COMPLETED = "COMPLETED" + const val STATUS_CLOSED = "CLOSED" + const val STATUS_EXPIRED = "EXPIRED" + const val STATUS_UNKNOWN = "UNKNOWN" + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt new file mode 100644 index 0000000000..0cc6fad622 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt @@ -0,0 +1,87 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.GroupKeyEnvelopeEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class GroupKeyEnvelopeDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: GroupKeyEnvelopeDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.groupKeyEnvelopeDao() + } + + @After + fun tearDown() { + db.close() + } + + private fun row(chat: String = "aa", pk: String = "01", payload: Byte = 1, wrappedBy: ByteArray? = null) = + GroupKeyEnvelopeEntity(chat, pk, 1, ByteArray(24) { payload }, ByteArray(40) { payload }, wrappedBy, null) + + @Test + fun `a row round-trips byte for byte`() = runTest { + val saved = row(wrappedBy = ByteArray(16) { 4 }) + dao.upsert(saved) + + assertEquals(saved, dao.find("aa", "01")) + } + + @Test + fun `upsert replaces the row for the same chat and key`() = runTest { + dao.upsert(row(payload = 1)) + dao.upsert(row(payload = 2)) + + assertEquals(row(payload = 2), dao.find("aa", "01")) + } + + @Test + fun `another account's key is a separate row`() = runTest { + dao.upsert(row(pk = "01", payload = 1)) + dao.upsert(row(pk = "02", payload = 2)) + dao.delete("aa", "01") + + assertNull(dao.find("aa", "01")) + assertEquals(row(pk = "02", payload = 2), dao.find("aa", "02")) + } + + @Test + fun `deleteForChat drops every own key's row for that chat only`() = runTest { + dao.upsert(row(chat = "aa", pk = "01")) + dao.upsert(row(chat = "aa", pk = "02")) + dao.upsert(row(chat = "bb", pk = "01")) + dao.deleteForChat("aa") + + assertNull(dao.find("aa", "01")) + assertNull(dao.find("aa", "02")) + assertEquals(row(chat = "bb", pk = "01"), dao.find("bb", "01")) + } + + @Test + fun `deleteAll empties the table`() = runTest { + dao.upsert(row(chat = "aa")) + dao.upsert(row(chat = "bb")) + dao.deleteAll() + + assertNull(dao.find("aa", "01")) + assertNull(dao.find("bb", "01")) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt new file mode 100644 index 0000000000..941462814d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt @@ -0,0 +1,147 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.PaymentRequestEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The `paymentrequest.v1` merge rule as the database enforces it: the greater version replaces the + * row whole, and at an equal version only EXPIRED over OPEN does. + */ +@RunWith(RobolectricTestRunner::class) +class PaymentRequestDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: PaymentRequestDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.paymentRequestDao() + } + + @After + fun tearDown() { + db.close() + } + + private fun entity( + version: Long, + status: String = PaymentRequestEntity.STATUS_OPEN, + payload: String = "v$version-$status", + id: String = ID, + chat: String = CHAT, + ) = PaymentRequestEntity( + idHex = id, + chatIdHex = chat, + version = version, + status = status, + endsAtEpochMs = 9_000, + createdAtEpochMs = 1_000, + request = payload.toByteArray(), + ) + + private suspend fun stored(id: String = ID) = dao.find(id)?.let { it.version to String(it.request) } + + @Test + fun `a request with no row is written`() = runTest { + dao.mergeIfNewer(entity(version = 3)) + + assertEquals(entity(version = 3), dao.find(ID)) + } + + @Test + fun `an older version keeps the stored row`() = runTest { + dao.mergeIfNewer(entity(version = 5)) + dao.mergeIfNewer(entity(version = 4, payload = "stale")) + + assertEquals(5L to "v5-OPEN", stored()) + } + + @Test + fun `a newer version replaces the whole row`() = runTest { + dao.mergeIfNewer(entity(version = 5, chat = "aa")) + dao.mergeIfNewer(entity(version = 6, status = PaymentRequestEntity.STATUS_COMPLETED, chat = "bb")) + + assertEquals(entity(version = 6, status = PaymentRequestEntity.STATUS_COMPLETED, chat = "bb"), dao.find(ID)) + } + + @Test + fun `an equal version EXPIRED replaces OPEN`() = runTest { + dao.mergeIfNewer(entity(version = 5)) + dao.mergeIfNewer(entity(version = 5, status = PaymentRequestEntity.STATUS_EXPIRED)) + + assertEquals(5L to "v5-EXPIRED", stored()) + assertEquals(PaymentRequestEntity.STATUS_EXPIRED, dao.find(ID)?.status) + } + + @Test + fun `an equal version OPEN does not replace EXPIRED`() = runTest { + dao.mergeIfNewer(entity(version = 5, status = PaymentRequestEntity.STATUS_EXPIRED)) + dao.mergeIfNewer(entity(version = 5, status = PaymentRequestEntity.STATUS_OPEN)) + + assertEquals(PaymentRequestEntity.STATUS_EXPIRED, dao.find(ID)?.status) + } + + @Test + fun `an equal version with any other status leaves the row alone`() = runTest { + dao.mergeIfNewer(entity(version = 5)) + dao.mergeIfNewer(entity(version = 5, status = PaymentRequestEntity.STATUS_CLOSED, payload = "other")) + + assertEquals(5L to "v5-OPEN", stored()) + } + + @Test + fun `an older version never replaces even an EXPIRED over OPEN`() = runTest { + dao.mergeIfNewer(entity(version = 5)) + dao.mergeIfNewer(entity(version = 4, status = PaymentRequestEntity.STATUS_EXPIRED)) + + assertEquals(5L to "v5-OPEN", stored()) + } + + @Test + fun `requests are listed by chat`() = runTest { + dao.mergeIfNewer(entity(version = 1, id = "01", chat = "aa")) + dao.mergeIfNewer(entity(version = 1, id = "02", chat = "aa")) + dao.mergeIfNewer(entity(version = 1, id = "03", chat = "bb")) + + assertEquals(setOf("01", "02"), dao.inChat("aa").map { it.idHex }.toSet()) + } + + @Test + fun `deleteForChat drops that chat's requests and keeps another chat's`() = runTest { + dao.mergeIfNewer(entity(version = 1, id = "01", chat = "aa")) + dao.mergeIfNewer(entity(version = 1, id = "02", chat = "aa")) + dao.mergeIfNewer(entity(version = 1, id = "03", chat = "bb")) + dao.deleteForChat("aa") + + assertEquals(emptyList(), dao.inChat("aa")) + assertEquals(listOf("03"), dao.inChat("bb").map { it.idHex }) + } + + @Test + fun `deleteAll clears every request`() = runTest { + dao.mergeIfNewer(entity(version = 1)) + dao.deleteAll() + + assertNull(dao.find(ID)) + } + + private companion object { + const val ID = "0a0b" + const val CHAT = "aa" + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index bf69f3b11d..48c839966a 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -1,6 +1,7 @@ package com.flipcash.app.persistence.sources import androidx.paging.PagingSource +import androidx.room.withTransaction import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper @@ -84,6 +85,26 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateMembership(chatIdHex, isMember) } + /** + * Records that the viewer is no longer in [chatId] and drops what was kept to read it offline: + * its group key envelopes (every own key) and its payment requests. The row itself stays, as a + * chat you have left is still one you can be shown, so this is the point where the chat's + * data goes rather than a row delete. One transaction, so a reader never sees the chat left + * with its key still stored. + */ + suspend fun removeFromChat(chatId: ChatId) { + removeFromChat(mapper.chatIdHex(chatId)) + } + + suspend fun removeFromChat(chatIdHex: String) { + val database = db ?: return + database.withTransaction { + database.chatMetadataDao().updateMembership(chatIdHex, false) + database.groupKeyEnvelopeDao().deleteForChat(chatIdHex) + database.paymentRequestDao().deleteForChat(chatIdHex) + } + } + /** The roster version already applied to [chatId]; 0 when the chat is not stored yet. */ suspend fun getRosterVersion(chatId: ChatId): Long = db?.chatMetadataDao()?.getRosterVersion(mapper.chatIdHex(chatId)) ?: 0L diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt new file mode 100644 index 0000000000..e39b01de44 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.GroupKeyEnvelopeEntity +import com.flipcash.services.chat.GroupKeyEnvelopeStore +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.StoredKeyEnvelope +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.hexEncodedString +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `group_key_envelopes` table: each private group's key envelope as the server returned it. + * No database means nothing stored rather than an error, like the other data sources here. + */ +@Singleton +class GroupKeyEnvelopeDataSource @Inject constructor() : GroupKeyEnvelopeStore { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + override suspend fun load(chatId: ChatId, ownPublicKey: ByteArray): StoredKeyEnvelope? = + db?.groupKeyEnvelopeDao()?.find(chatId.hex(), ownPublicKey.hex())?.toStored() + + override suspend fun save(chatId: ChatId, ownPublicKey: ByteArray, envelope: StoredKeyEnvelope) { + db?.groupKeyEnvelopeDao()?.upsert( + GroupKeyEnvelopeEntity( + chatIdHex = chatId.hex(), + ownPkHex = ownPublicKey.hex(), + scheme = envelope.envelope.scheme, + nonce = envelope.envelope.nonce, + ciphertext = envelope.envelope.ciphertext, + wrappedBy = envelope.wrappedBy?.toByteArray(), + wrappedByPublicKey = envelope.wrappedByPublicKey?.byteArray, + ) + ) + } + + override suspend fun delete(chatId: ChatId, ownPublicKey: ByteArray) { + db?.groupKeyEnvelopeDao()?.delete(chatId.hex(), ownPublicKey.hex()) + } + + override suspend fun clear() { + db?.groupKeyEnvelopeDao()?.deleteAll() + } + + private fun ChatId.hex() = bytes.hex() + private fun ByteArray.hex() = toList().hexEncodedString() + + private fun GroupKeyEnvelopeEntity.toStored() = StoredKeyEnvelope( + envelope = KeyEnvelope(scheme = scheme, nonce = nonce, ciphertext = ciphertext), + wrappedBy = wrappedBy?.toList(), + wrappedByPublicKey = wrappedByPublicKey?.let { PublicKey(it.toList()) }, + ) +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt new file mode 100644 index 0000000000..0d475ef1fd --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.PaymentRequestEntity +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.decodePaymentRequest +import com.flipcash.services.models.paymentrequest.encodePaymentRequest +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `payment_requests` table: each request as the serialized `PaymentRequestMetadata` it was + * received as, with the columns the merge and the per-chat query read. + * + * Text is stored in its encrypted form, so what [inChat] and [find] return still needs opening. + * No database means nothing stored rather than an error, like the other data sources here. + */ +@Singleton +class PaymentRequestDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + /** + * Writes each request unless the stored copy is newer (the rule is in + * `PaymentRequestDao.mergeIfNewer`, enforced by the database). + */ + suspend fun merge(requests: List) { + val dao = db?.paymentRequestDao() ?: return + requests.forEach { dao.mergeIfNewer(it.toEntity()) } + } + + suspend fun inChat(chatId: ChatId): List = + db?.paymentRequestDao()?.inChat(chatId.bytes.toList().hexEncodedString()) + ?.mapNotNull { it.toRequest() } + .orEmpty() + + suspend fun find(id: ID): PaymentRequest? = + db?.paymentRequestDao()?.find(id.hexEncodedString())?.toRequest() + + suspend fun clear() { + db?.paymentRequestDao()?.deleteAll() + } + + // A row whose bytes no longer parse reads as absent; the next fetch replaces it. + private fun PaymentRequestEntity.toRequest(): PaymentRequest? = decodePaymentRequest(request) + + private fun PaymentRequest.toEntity() = PaymentRequestEntity( + idHex = id.hexEncodedString(), + chatIdHex = chatId?.bytes?.toList()?.hexEncodedString().orEmpty(), + version = version, + status = status.toColumn(), + endsAtEpochMs = split?.endsAt?.toEpochMilliseconds(), + createdAtEpochMs = createdAt.toEpochMilliseconds(), + request = encodePaymentRequest(), + ) + + private fun PaymentRequestStatus.toColumn(): String = when (this) { + PaymentRequestStatus.Open -> PaymentRequestEntity.STATUS_OPEN + PaymentRequestStatus.Completed -> PaymentRequestEntity.STATUS_COMPLETED + PaymentRequestStatus.Closed -> PaymentRequestEntity.STATUS_CLOSED + PaymentRequestStatus.Expired -> PaymentRequestEntity.STATUS_EXPIRED + PaymentRequestStatus.Unknown -> PaymentRequestEntity.STATUS_UNKNOWN + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediator.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediator.kt index 013778c44c..0fcc415405 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediator.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediator.kt @@ -146,7 +146,7 @@ class ChatFeedRemoteMediator( } /** - * Clears the membership of any group this device holds that a complete pass did not return. + * Clears the membership of any group (and drops its key envelope and payment requests) this device holds that a complete pass did not return. * * The event stream is the primary path — `MemberLeft` naming the recipient, and `LeaveChat` * clearing it optimistically. This is the backstop for a removal that happened while the @@ -157,7 +157,7 @@ class ChatFeedRemoteMediator( if (ChatType.GROUP !in chatTypes) return for (chatIdHex in metadataDataSource.getChatIdsOfType(ChatType.GROUP)) { if (chatIdHex in seenGroupIds) continue - metadataDataSource.setMembership(chatIdHex, isMember = false) + metadataDataSource.removeFromChat(chatIdHex) } } } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt new file mode 100644 index 0000000000..91b76f0005 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt @@ -0,0 +1,121 @@ +package com.flipcash.app.persistence.sources + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.entities.GroupKeyEnvelopeEntity +import com.flipcash.app.persistence.entities.PaymentRequestEntity +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Leaving, being removed from, or reconciling away a group drops that chat's key envelopes and + * payment requests and leaves every other chat's alone. Each path ends in `removeFromChat`; the + * callers are covered where they live. + */ +@RunWith(RobolectricTestRunner::class) +class ChatRemovalDataTest { + + private lateinit var db: FlipcashDatabase + private val dataSource = ChatMetadataDataSource(ChatEntityMapper()) + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + mockkObject(FlipcashDatabase.Companion) + every { FlipcashDatabase.getInstance() } returns db + } + + @After + fun tearDown() { + unmockkObject(FlipcashDatabase.Companion) + db.close() + } + + private suspend fun seed(chat: String) { + db.chatMetadataDao().upsert( + ChatMetadataEntity( + chatIdHex = chat, + chatType = "GROUP", + lastActivityEpochMs = 1, + lastMessageId = null, + isMember = true, + ) + ) + // Two own keys for the same chat: both go. + for (pk in listOf("01", "02")) { + db.groupKeyEnvelopeDao().upsert( + GroupKeyEnvelopeEntity(chat, pk, 1, ByteArray(24), ByteArray(40), null, null) + ) + } + db.paymentRequestDao().mergeIfNewer( + PaymentRequestEntity( + idHex = "req-$chat", + chatIdHex = chat, + version = 1, + status = PaymentRequestEntity.STATUS_OPEN, + endsAtEpochMs = null, + createdAtEpochMs = 1, + request = byteArrayOf(1), + ) + ) + } + + private suspend fun assertKept(chat: String) { + assertNotNull(db.groupKeyEnvelopeDao().find(chat, "01")) + assertNotNull(db.groupKeyEnvelopeDao().find(chat, "02")) + assertEquals(1, db.paymentRequestDao().inChat(chat).size) + } + + @Test + fun `removing a chat drops its envelopes and requests and keeps another chat's`() = runTest { + seed("aa") + seed("bb") + + dataSource.removeFromChat(ChatId(listOf(0xAA.toByte()))) + + assertNull(db.groupKeyEnvelopeDao().find("aa", "01")) + assertNull(db.groupKeyEnvelopeDao().find("aa", "02")) + assertTrue(db.paymentRequestDao().inChat("aa").isEmpty()) + assertKept("bb") + } + + @Test + fun `removing a chat clears its membership but keeps the row`() = runTest { + seed("aa") + seed("bb") + + dataSource.removeFromChat("aa") + + assertFalse(db.chatMetadataDao().getById("aa")!!.isMember) + assertTrue(db.chatMetadataDao().getById("bb")!!.isMember) + } + + @Test + fun `removing a chat that holds nothing is a no-op for the others`() = runTest { + seed("bb") + + dataSource.removeFromChat("aa") + + assertKept("bb") + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediatorTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediatorTest.kt index 9a98c82c85..afeb0f458e 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediatorTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mediator/ChatFeedRemoteMediatorTest.kt @@ -174,12 +174,12 @@ class ChatFeedRemoteMediatorTest { groupReturns(page(stillThere, hasMore = false)) coEvery { metadataDataSource.chatIdHex(stillThere.chatId) } returns "01" coEvery { metadataDataSource.getChatIdsOfType(ChatType.GROUP) } returns listOf("01", "02") - coEvery { metadataDataSource.setMembership(any(), any()) } just Runs + coEvery { metadataDataSource.removeFromChat(any()) } just Runs mediator().load(LoadType.REFRESH, state) - coVerify { metadataDataSource.setMembership("02", false) } - coVerify(exactly = 0) { metadataDataSource.setMembership("01", false) } + coVerify { metadataDataSource.removeFromChat("02") } + coVerify(exactly = 0) { metadataDataSource.removeFromChat("01") } } /** @@ -196,6 +196,6 @@ class ChatFeedRemoteMediatorTest { mediator().load(LoadType.REFRESH, state) coVerify(exactly = 0) { metadataDataSource.getChatIdsOfType(any()) } - coVerify(exactly = 0) { metadataDataSource.setMembership(any(), any()) } + coVerify(exactly = 0) { metadataDataSource.removeFromChat(any()) } } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt new file mode 100644 index 0000000000..5f289a4954 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt @@ -0,0 +1,20 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.StoredKeyEnvelope + +/** + * Where [GroupKeyEnvelopes] keeps each private group's key envelope between launches, so a chat + * opens offline. Holds the envelope as the server returned it, still wrapped; the chat key is never + * passed to it. One entry per chat and own public key. + */ +interface GroupKeyEnvelopeStore { + suspend fun load(chatId: ChatId, ownPublicKey: ByteArray): StoredKeyEnvelope? + + /** Stores [envelope], replacing any earlier one for the same chat and key. */ + suspend fun save(chatId: ChatId, ownPublicKey: ByteArray, envelope: StoredKeyEnvelope) + + suspend fun delete(chatId: ChatId, ownPublicKey: ByteArray) + + suspend fun clear() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 2b701a1f2f..5fcd5d8287 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -6,12 +6,14 @@ import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.InviteKeyBundle import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.StoredKeyEnvelope import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.EncryptedPayload import com.getcode.chatcipher.InviteBundleResult import com.getcode.chatcipher.InviteCipher import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap @@ -32,6 +34,7 @@ class GroupKeyEnvelopes @Inject constructor( private val keys: ChatKeySource, private val chatController: ChatController, private val wrongKeyChats: WrongKeyChats, + private val store: GroupKeyEnvelopeStore, ) : GroupKeySource { // Own public key in the key so a different account on this device never reuses a group key. private data class CacheKey(val chatId: ID, val ownPk: ID) @@ -54,9 +57,17 @@ class GroupKeyEnvelopes @Inject constructor( val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) return chatController.setKeyEnvelope(chatId, envelope) - .onSuccess { groupKeys[cacheKey] = groupKey } + .onSuccess { + saveEnvelope(chatId, own, StoredKeyEnvelope(envelope, wrappedBy = null)) + groupKeys[cacheKey] = groupKey + } .recoverCatching { error -> - if (error is SetKeyEnvelopeError.AlreadySet) groupKeys.remove(cacheKey) else throw error + if (error is SetKeyEnvelopeError.AlreadySet) { + // The envelope stored first stands, and it isn't this one: neither the cache nor + // the stored row may keep a key generated here. + groupKeys.remove(cacheKey) + dropEnvelope(chatId, own) + } else throw error } } @@ -69,7 +80,10 @@ class GroupKeyEnvelopes @Inject constructor( } /** - * The viewer's copy of [chatId]'s chat key, fetched once per chat per launch and kept in memory. + * The viewer's copy of [chatId]'s chat key, kept in memory. On a miss the envelope stored by an + * earlier launch is opened first, with the same checks as a fetched one and no network call; if + * there is none, or it fails a check, the envelope is fetched and replaces the stored one. Only + * the envelope is stored, never the key. * * A self-wrapped envelope opens under the viewer's own key. One the creator wrapped when * admitting the viewer opens under the creator's key, which comes from the Resolver for @@ -93,28 +107,62 @@ class GroupKeyEnvelopes @Inject constructor( return locks.getOrPut(cacheKey) { Mutex() }.withLock { groupKeys[cacheKey]?.let { return@withLock Result.success(it) } + openStored(chatId, own)?.let { + groupKeys[cacheKey] = it + wrongKeyChats.clear(chatId) + return@withLock Result.success(it) + } fetchKey(chatId, own) .onSuccess { groupKeys[cacheKey] = it wrongKeyChats.clear(chatId) } + // Covers every refusal below, including the wrong-key mark: a stored envelope this + // client won't trust must not open the chat on the next launch. + .onFailure { if (it is UntrustedEnvelope) dropEnvelope(chatId, own) } } } + /** + * The key from the envelope stored for [chatId], or null when there is none or it can't be + * used (the caller then fetches). One that fails a trust check is dropped; one that merely + * couldn't be checked yet, such as a creator-wrapped envelope offline, is kept. + */ + private suspend fun openStored(chatId: ChatId, own: KeyPair): ByteArray? { + val stored = loadEnvelope(chatId, own) ?: return null + return openEnvelope(chatId, own, stored, retried = false) + .onFailure { if (it is UntrustedEnvelope) dropEnvelope(chatId, own) } + .getOrNull() + } + private suspend fun fetchKey(chatId: ChatId, own: KeyPair, retried: Boolean = false): Result { val stored = chatController.getKeyEnvelope(chatId).getOrElse { error -> - if (error is GetKeyEnvelopeError.NoEnvelope && !retried && viewerIsCreator(chatId)) { - // storeNewKey caches the key it stored; ALREADY_SET leaves nothing cached, so read that one. - return storeNewKey(chatId).mapCatching { - groupKeys[CacheKey(chatId.bytes.toList(), own.publicKey.toList())] - ?: fetchKey(chatId, own, retried = true).getOrThrow() + if (error is GetKeyEnvelopeError.NoEnvelope) { + if (!retried && viewerIsCreator(chatId)) { + // storeNewKey caches the key it stored; ALREADY_SET leaves nothing cached, so read that one. + return storeNewKey(chatId).mapCatching { + groupKeys[CacheKey(chatId.bytes.toList(), own.publicKey.toList())] + ?: fetchKey(chatId, own, retried = true).getOrThrow() + } } + // The server holds nothing for the viewer, so a stored envelope is stale. + dropEnvelope(chatId, own) } if (error is GetKeyEnvelopeError.InviteKeyBundleOnly) { return openInviteBundle(chatId, own, error.bundle, retried) } return Result.failure(error) } + return openEnvelope(chatId, own, stored, retried) + } + + /** Checks and opens [stored], whether it was just fetched or read back from the store. */ + private suspend fun openEnvelope( + chatId: ChatId, + own: KeyPair, + stored: StoredKeyEnvelope, + retried: Boolean, + ): Result { if (stored.envelope.scheme != KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305) { return Result.failure(UntrustedEnvelope("Unsupported envelope scheme ${stored.envelope.scheme}")) } @@ -124,6 +172,7 @@ class GroupKeyEnvelopes @Inject constructor( if (wrapper == null || wrapper == keys.ownUserId()) { return runCatching { cipher.unwrapGroupKey(own, own.publicKey, chatId.bytes, payload) } .recoverCatching { throw UntrustedEnvelope("Self-wrapped envelope did not open", it) } + .onSuccess { saveEnvelope(chatId, own, stored) } } val creator = chatController.getChat(chatId).getOrElse { return Result.failure(it) }.creator @@ -133,7 +182,7 @@ class GroupKeyEnvelopes @Inject constructor( val creatorPk = keys.peerPublicKey(creator).getOrElse { return Result.failure(it) } val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } - return storeSelfCopy(chatId, own, groupKey, retried) + return storeSelfCopy(chatId, own, groupKey, retried, keepOnFailure = stored) } /** @@ -169,7 +218,7 @@ class GroupKeyEnvelopes @Inject constructor( secretEnvelope = secretEnvelope, ) return when (opened) { - is InviteBundleResult.Opened -> storeSelfCopy(chatId, own, opened.chatKey, retried) + is InviteBundleResult.Opened -> storeSelfCopy(chatId, own, opened.chatKey, retried, keepOnFailure = null) is InviteBundleResult.Rejected -> wrongKey(chatId, "Invite key bundle rejected at ${opened.failsAt}") } } @@ -179,22 +228,60 @@ class GroupKeyEnvelopes @Inject constructor( return Result.failure(UntrustedEnvelope(reason)) } - private suspend fun storeSelfCopy(chatId: ChatId, own: KeyPair, groupKey: ByteArray, retried: Boolean): Result { + /** + * Stores the member's own copy of the key on the server and, with it, in [store]: the self-wrapped + * envelope replaces the creator's or the invite's copy there. [keepOnFailure] is the envelope the + * server still holds when the copy can't be stored (null for an invite's bundle, which is not a + * `KeyEnvelope` and is never stored). + */ + private suspend fun storeSelfCopy( + chatId: ChatId, + own: KeyPair, + groupKey: ByteArray, + retried: Boolean, + keepOnFailure: StoredKeyEnvelope?, + ): Result { // The self-wrapped copy only saves the next launch a trip through the creator's envelope, so // failing to store it doesn't cost the key that just opened. val selfWrapped = runCatching { cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() - }.getOrElse { return Result.success(groupKey) } + }.getOrElse { + keepOrDrop(chatId, own, keepOnFailure) + return Result.success(groupKey) + } return chatController.setKeyEnvelope(chatId, selfWrapped).fold( - onSuccess = { Result.success(groupKey) }, + onSuccess = { + saveEnvelope(chatId, own, StoredKeyEnvelope(selfWrapped, wrappedBy = null)) + Result.success(groupKey) + }, onFailure = { error -> // Another device re-wrapped first; its envelope stands, so read that one. if (error is SetKeyEnvelopeError.AlreadySet && !retried) fetchKey(chatId, own, retried = true) - else Result.success(groupKey) + else { + keepOrDrop(chatId, own, keepOnFailure) + Result.success(groupKey) + } }, ) } + private suspend fun keepOrDrop(chatId: ChatId, own: KeyPair, envelope: StoredKeyEnvelope?) { + if (envelope != null) saveEnvelope(chatId, own, envelope) else dropEnvelope(chatId, own) + } + + // The store is a cache of the server's envelope: a failing database costs the offline open, never + // the key, so its errors are swallowed. + private suspend fun loadEnvelope(chatId: ChatId, own: KeyPair): StoredKeyEnvelope? = + try { store.load(chatId, own.publicKey) } catch (e: CancellationException) { throw e } catch (_: Exception) { null } + + private suspend fun saveEnvelope(chatId: ChatId, own: KeyPair, envelope: StoredKeyEnvelope) { + try { store.save(chatId, own.publicKey, envelope) } catch (e: CancellationException) { throw e } catch (_: Exception) { } + } + + private suspend fun dropEnvelope(chatId: ChatId, own: KeyPair) { + try { store.delete(chatId, own.publicKey) } catch (e: CancellationException) { throw e } catch (_: Exception) { } + } + private suspend fun viewerIsCreator(chatId: ChatId): Boolean { val self = keys.ownUserId() ?: return false return chatController.getChat(chatId).getOrNull()?.creator == self diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt index 50d2ef3caf..3bc9454ffa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt @@ -74,6 +74,9 @@ data class Split( val currency: CurrencyCode, val endsAt: Instant, val shares: List, + // The sealed form [text] was opened from, so persistence stores the text as the server sent it + // (see [encodePaymentRequest]). Null when the text was never sealed or is still sealed. + val sealedText: PaymentRequestText.Encrypted? = null, ) { val requested: Fiat get() = Fiat(quarks = shares.sumOf { it.amount.quarks }, currencyCode = currency) val paid: Fiat get() = Fiat(quarks = shares.filter { it.isPaid }.sumOf { it.amount.quarks }, currencyCode = currency) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt new file mode 100644 index 0000000000..4cbeb845c5 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt @@ -0,0 +1,100 @@ +package com.flipcash.services.models.paymentrequest + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel +import com.flipcash.services.internal.network.extensions.toPaymentRequest +import com.getcode.opencode.model.financial.Fiat +import com.google.protobuf.ByteString +import com.google.protobuf.InvalidProtocolBufferException +import com.google.protobuf.Timestamp +import kotlin.time.Instant + +/** + * The request as `paymentrequest.v1.PaymentRequestMetadata` bytes, the form persistence stores. + * + * It is the whole request at its version, so a request an update has changed is stored the same + * way as one fetched. Text is written in the form it arrived in: a split whose text was opened + * keeps the [Split.sealedText] it came from, so a private chat's text never reaches the database + * in the clear. A payment's intent id is never held by the app and is not written; a share paid + * by payment keeps an empty `intent` arm so it reads back as paid by payment. + * + * A request of a kind the app doesn't know is written without its kind and reads back as + * [PaymentRequest.Kind.Unsupported], which is how it renders anyway. + */ +fun PaymentRequest.encodePaymentRequest(): ByteArray = toMetadata().toByteArray() + +/** The request [encodePaymentRequest] wrote, or null when [bytes] no longer parse. */ +fun decodePaymentRequest(bytes: ByteArray): PaymentRequest? = try { + PaymentRequestModel.PaymentRequestMetadata.parseFrom(bytes).toPaymentRequest() +} catch (_: InvalidProtocolBufferException) { + null +} + +private fun PaymentRequest.toMetadata(): PaymentRequestModel.PaymentRequestMetadata { + val builder = PaymentRequestModel.PaymentRequestMetadata.newBuilder() + .setPaymentRequestId(PaymentRequestModel.PaymentRequestId.newBuilder().setValue(id.toByteString())) + .setCreator(Common.UserId.newBuilder().setValue(creator.toByteString())) + .setCreatedAt(createdAt.toTimestamp()) + .setStatus(status.toProto()) + .setVersion(version) + closedAt?.let { builder.setClosedAt(it.toTimestamp()) } + val kind = kind + if (kind is PaymentRequest.Kind.GroupSplit) { + builder.setGroupChat( + PaymentRequestModel.GroupChatPaymentRequest.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(ByteString.copyFrom(kind.chatId.bytes))) + .setSplit(kind.split.toProto()), + ) + } + return builder.build() +} + +private fun Split.toProto(): PaymentRequestModel.Split { + val builder = PaymentRequestModel.Split.newBuilder() + .setCurrency(currency.name.lowercase()) + .setEndsAt(endsAt.toTimestamp()) + when (val stored = sealedText ?: text) { + is PaymentRequestText.Plain -> builder.setPlaintext( + PaymentRequestModel.PaymentRequestText.newBuilder().setTitle(stored.title).setDescription(stored.description), + ) + is PaymentRequestText.Encrypted -> builder.setEncrypted( + PaymentRequestModel.EncryptedPaymentRequestText.newBuilder() + .setSchemeValue(stored.scheme) + .setNonce(ByteString.copyFrom(stored.nonce)) + .setCiphertext(ByteString.copyFrom(stored.ciphertext)), + ) + PaymentRequestText.Undecryptable -> Unit + } + shares.forEach { share -> + val shareBuilder = PaymentRequestModel.Share.newBuilder() + .setUserId(Common.UserId.newBuilder().setValue(share.userId.toByteString())) + .setNativeAmount(share.amount.quarks / Fiat.MULTIPLIER) + share.paid?.let { shareBuilder.setPaid(it.toProto()) } + builder.addShares(shareBuilder) + } + return builder.build() +} + +private fun PaidShare.toProto(): PaymentRequestModel.PaidShare { + val builder = PaymentRequestModel.PaidShare.newBuilder().setPaidAt(paidAt.toTimestamp()) + when (how) { + PaidShare.How.Payment -> builder.setIntent(Common.IntentId.getDefaultInstance()) + PaidShare.How.Manual -> builder.setManual(PaymentRequestModel.PaidShare.Manual.getDefaultInstance()) + PaidShare.How.Creator -> builder.setCreator(PaymentRequestModel.PaidShare.Creator.getDefaultInstance()) + PaidShare.How.Unknown -> Unit + } + return builder.build() +} + +private fun PaymentRequestStatus.toProto(): PaymentRequestModel.PaymentRequestStatus = when (this) { + PaymentRequestStatus.Open -> PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN + PaymentRequestStatus.Completed -> PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED + PaymentRequestStatus.Closed -> PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED + PaymentRequestStatus.Expired -> PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED + PaymentRequestStatus.Unknown -> PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_UNKNOWN +} + +private fun List.toByteString(): ByteString = ByteString.copyFrom(toByteArray()) + +private fun Instant.toTimestamp(): Timestamp = + Timestamp.newBuilder().setSeconds(epochSeconds).setNanos(nanosecondsOfSecond).build() diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 1cb3465d79..affab6dafd 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -38,7 +38,8 @@ class GroupKeyEnvelopesTest { override suspend fun peerPublicKey(userId: ID): Result = error("unused") } private val controller = mockk() - private val envelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), keys, controller, WrongKeyChats()) + private val store = InMemoryGroupKeyEnvelopeStore() + private val envelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), keys, controller, WrongKeyChats(), store) @Test fun `storeNewKey stores a self-wrapped envelope`() = runTest { @@ -109,7 +110,7 @@ class GroupKeyEnvelopesTest { override suspend fun peerPublicKey(userId: ID): Result = if (userId == creatorId) Result.success(creatorKeys.publicKey) else Result.failure(IOException("unknown")) } - private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats) + private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats, store) private val groupKey = ByteArray(32) { 5 } private fun stored(wrapper: KeyPair, recipient: ByteArray, wrappedBy: ID?): StoredKeyEnvelope { @@ -200,7 +201,7 @@ class GroupKeyEnvelopesTest { override fun ownUserId() = creatorId override suspend fun peerPublicKey(userId: ID): Result = error("unused") } - val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), creatorSource, controller, WrongKeyChats()) + val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), creatorSource, controller, WrongKeyChats(), store) coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) @@ -298,7 +299,7 @@ class GroupKeyEnvelopesTest { override fun ownUserId() = memberId override suspend fun peerPublicKey(userId: ID): Result = Result.failure(IOException("offline")) } - val envelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, offline, controller, wrongKeyChats) + val envelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, offline, controller, wrongKeyChats, store) coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(wrappedByPublicKey = PublicKey(creatorKeys.publicKey.toList()))) coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) @@ -360,4 +361,226 @@ class GroupKeyEnvelopesTest { memberEnvelopes.key(chatId).getOrThrow() assertFalse(chatId in wrongKeyChats.chats.value) } + + // Persistence: the envelope is stored as fetched, and the stored row follows the in-memory key. + + private val rowKey get() = chatId.bytes.toList() to memberPk.toList() + private fun restarted() = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats, store) + + private fun selfWrapped() = stored(memberKeys, memberPk, memberId) + + @Test + fun `a network fetch stores the envelope as returned`() = runTest { + val fetched = selfWrapped() + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(fetched) + + memberEnvelopes.key(chatId).getOrThrow() + + assertEquals(fetched, store.rows[rowKey]) + } + + @Test + fun `the stored row holds the wrapped envelope and never the unwrapped key`() = runTest { + val fetched = selfWrapped() + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(fetched) + + memberEnvelopes.key(chatId).getOrThrow() + + val row = store.rows.getValue(rowKey) + assertContentEquals(fetched.envelope.nonce, row.envelope.nonce) + assertContentEquals(fetched.envelope.ciphertext, row.envelope.ciphertext) + assertFalse(row.envelope.nonce.contentEquals(groupKey)) + assertFalse(row.envelope.ciphertext.contentEquals(groupKey)) + assertTrue(row.wrappedBy == null || row.wrappedBy == memberId) + } + + @Test + fun `a stored envelope opens after a restart with the network failing`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(selfWrapped()) + memberEnvelopes.key(chatId).getOrThrow() + + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))) + coEvery { controller.getChat(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, restarted().key(chatId).getOrThrow()) + coVerify(exactly = 1) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a stored creator-wrapped envelope that can't be checked offline is kept and the fetch failure returned`() = runTest { + val creatorWrapped = stored(creatorKeys, memberPk, creatorId) + store.rows[rowKey] = creatorWrapped + coEvery { controller.getChat(chatId, any()) } returns Result.failure(IOException("offline")) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertEquals(creatorWrapped, store.rows[rowKey]) + } + + @Test + fun `a network fetch replaces the stored envelope`() = runTest { + store.rows[rowKey] = stored(creatorKeys, memberPk, creatorId) + // The stored creator-wrapped copy opens and is re-wrapped: the self-wrapped copy replaces it. + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + memberEnvelopes.key(chatId).getOrThrow() + + assertEquals(rewrapped.captured, store.rows[rowKey]?.envelope) + coVerify(exactly = 0) { controller.getKeyEnvelope(any()) } + } + + @Test + fun `a stored envelope that fails to unwrap falls back to the network and is replaced`() = runTest { + val impostor = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)) + store.rows[rowKey] = stored(impostor, memberPk, memberId) + val fetched = selfWrapped() + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(fetched) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + + coVerify(exactly = 1) { controller.getKeyEnvelope(chatId) } + assertEquals(fetched, store.rows[rowKey]) + } + + @Test + fun `a stored envelope in an unknown scheme is dropped and the network used`() = runTest { + val sealed = selfWrapped().envelope + store.rows[rowKey] = StoredKeyEnvelope(KeyEnvelope(99, sealed.nonce, sealed.ciphertext), wrappedBy = null) + val fetched = selfWrapped() + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(fetched) + + memberEnvelopes.key(chatId).getOrThrow() + + assertEquals(fetched, store.rows[rowKey]) + } + + @Test + fun `a stored envelope wrapped by a stranger is dropped and the network used`() = runTest { + store.rows[rowKey] = stored(creatorKeys, memberPk, List(16) { 9 }) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val fetched = selfWrapped() + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(fetched) + + memberEnvelopes.key(chatId).getOrThrow() + + assertEquals(fetched, store.rows[rowKey]) + } + + @Test + fun `an untrusted fetched envelope clears the stored row`() = runTest { + val impostor = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)) + store.rows[rowKey] = stored(impostor, memberPk, memberId) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(impostor, memberPk, memberId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + assertEquals(null, store.rows[rowKey]) + } + + @Test + fun `a wrong-key invite bundle clears the stored row`() = runTest { + store.rows[rowKey] = stored(memberKeys, memberPk, List(16) { 9 }) // fails the wrapper check + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(wrappedBy = List(16) { 9 })) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertTrue(chatId in wrongKeyChats.chats.value) + assertEquals(null, store.rows[rowKey]) + } + + @Test + fun `an invite bundle's copy is replaced by the member's own stored envelope`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + memberEnvelopes.key(chatId).getOrThrow() + + assertEquals(rewrapped.captured, store.rows[rowKey]?.envelope) + assertContentEquals(memberPk + memberPk + groupKey, store.rows.getValue(rowKey).envelope.ciphertext) + } + + @Test + fun `an invite bundle whose own copy can't be stored leaves no stored row`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + assertEquals(null, store.rows[rowKey]) + } + + @Test + fun `NO_ENVELOPE clears a stored row for a non-creator`() = runTest { + store.rows[rowKey] = stored(impostorKeys(), memberPk, memberId) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertEquals(null, store.rows[rowKey]) + } + + private fun impostorKeys() = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)) + + @Test + fun `a failed fetch other than a refusal keeps the stored row`() = runTest { + // A stored creator-wrapped copy that needs the network to check, with the network down. + val kept = stored(creatorKeys, memberPk, creatorId) + store.rows[rowKey] = kept + coEvery { controller.getChat(chatId, any()) } returns Result.failure(IOException("offline")) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertEquals(kept, store.rows[rowKey]) + } + + @Test + fun `storeNewKey stores the creator's envelope`() = runTest { + val sent = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(sent)) } returns Result.success(Unit) + + envelopes.storeNewKey(chatId).getOrThrow() + + val row = store.rows[chatId.bytes.toList() to creatorKeys.publicKey.toList()] + assertEquals(sent.captured, row?.envelope) + assertFalse(row!!.envelope.ciphertext.contentEquals(FakeChatCipher.newGroupKey())) + } + + @Test + fun `storeNewKey replaces an older stored envelope`() = runTest { + val rowKey = chatId.bytes.toList() to creatorKeys.publicKey.toList() + store.rows[rowKey] = stored(creatorKeys, creatorKeys.publicKey, null) + val sent = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(sent)) } returns Result.success(Unit) + + envelopes.storeNewKey(chatId).getOrThrow() + + assertEquals(sent.captured, store.rows[rowKey]?.envelope) + } + + @Test + fun `ALREADY_SET on storeNewKey clears the stored row`() = runTest { + val rowKey = chatId.bytes.toList() to creatorKeys.publicKey.toList() + store.rows[rowKey] = stored(creatorKeys, creatorKeys.publicKey, null) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertTrue(envelopes.storeNewKey(chatId).isSuccess) + assertEquals(null, store.rows[rowKey]) + } + + @Test + fun `a failing store never costs the key`() = runTest { + val broken = object : GroupKeyEnvelopeStore { + override suspend fun load(chatId: ChatId, ownPublicKey: ByteArray): StoredKeyEnvelope? = error("db") + override suspend fun save(chatId: ChatId, ownPublicKey: ByteArray, envelope: StoredKeyEnvelope) = error("db") + override suspend fun delete(chatId: ChatId, ownPublicKey: ByteArray) = error("db") + override suspend fun clear() = error("db") + } + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(selfWrapped()) + + val opened = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats, broken) + assertContentEquals(groupKey, opened.key(chatId).getOrThrow()) + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt new file mode 100644 index 0000000000..24483e6c30 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.services.models.paymentrequest + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.Instant + +/** A request survives the `PaymentRequestMetadata` bytes persistence stores. */ +class PaymentRequestStorageTest { + + private val alice = List(16) { 2 } + private val bob = List(16) { 3 } + private val chatId = ChatId(ByteArray(32) { 3 }) + private val encrypted = PaymentRequestText.Encrypted( + scheme = PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305, + nonce = ByteArray(24) { 7 }, + ciphertext = ByteArray(40) { 9 }, + ) + + private fun share(user: List, quarks: Long, paid: PaidShare? = null) = + Share(user, Fiat(quarks = quarks, currencyCode = CurrencyCode.USD), paid) + + private fun request(text: PaymentRequestText, sealedText: PaymentRequestText.Encrypted? = null) = PaymentRequest( + id = List(16) { 9 }, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(100, 500), + status = PaymentRequestStatus.Open, + closedAt = null, + version = 7, + kind = PaymentRequest.Kind.GroupSplit( + chatId = chatId, + split = Split( + text = text, + currency = CurrencyCode.USD, + endsAt = Instant.fromEpochSeconds(1_000), + shares = listOf( + share(alice, 12_345_670, PaidShare(Instant.fromEpochSeconds(200), PaidShare.How.Manual)), + share(bob, 3_333_333), + ), + sealedText = sealedText, + ), + ), + ) + + @Test + fun `a plaintext request round trips`() { + val request = request(PaymentRequestText.Plain("Dinner", "Tacos")) + + assertEquals(request, decodePaymentRequest(request.encodePaymentRequest())) + } + + @Test + fun `every way a share is paid round trips`() { + val hows = listOf(PaidShare.How.Payment, PaidShare.How.Manual, PaidShare.How.Creator, PaidShare.How.Unknown) + val base = request(PaymentRequestText.Plain("t", "")) + val split = base.split!!.copy(shares = hows.map { share(alice, 1, PaidShare(Instant.fromEpochSeconds(5), it)) }) + val withPaid = base.copy(kind = PaymentRequest.Kind.GroupSplit(chatId, split), closedAt = Instant.fromEpochSeconds(900), status = PaymentRequestStatus.Closed) + + assertEquals(withPaid, decodePaymentRequest(withPaid.encodePaymentRequest())) + } + + @Test + fun `an unopened request keeps its encrypted text`() { + val decoded = decodePaymentRequest(request(encrypted).encodePaymentRequest())!! + + val text = assertIs(decoded.split?.text) + assertEquals(encrypted.scheme, text.scheme) + assertContentEquals(encrypted.nonce, text.nonce) + assertContentEquals(encrypted.ciphertext, text.ciphertext) + } + + @Test + fun `an opened request is stored with the sealed text it came from, never the plain one`() { + val opened = request(PaymentRequestText.Plain("Secret dinner", "Tacos"), sealedText = encrypted) + + val parsed = PaymentRequestModel.PaymentRequestMetadata.parseFrom(opened.encodePaymentRequest()) + + assertEquals(PaymentRequestModel.Split.TextCase.ENCRYPTED, parsed.groupChat.split.textCase) + assertContentEquals(encrypted.ciphertext, parsed.groupChat.split.encrypted.ciphertext.toByteArray()) + val decoded = decodePaymentRequest(opened.encodePaymentRequest())!! + assertIs(decoded.split?.text) + } + + @Test + fun `a request of an unknown kind reads back as unsupported`() { + val unsupported = request(PaymentRequestText.Plain("t", "")).copy(kind = PaymentRequest.Kind.Unsupported) + + val decoded = decodePaymentRequest(unsupported.encodePaymentRequest())!! + + assertEquals(PaymentRequest.Kind.Unsupported, decoded.kind) + assertEquals(unsupported.version, decoded.version) + } + + @Test + fun `bytes that do not parse read as no request`() { + assertNull(decodePaymentRequest(byteArrayOf(0x0a, 0x7f, 0x01))) + } +} diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt new file mode 100644 index 0000000000..3da90030d7 --- /dev/null +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt @@ -0,0 +1,23 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.StoredKeyEnvelope + +/** A [GroupKeyEnvelopeStore] over a map, for tests; a second instance over the same map is a restart. */ +class InMemoryGroupKeyEnvelopeStore( + val rows: MutableMap, List>, StoredKeyEnvelope> = mutableMapOf(), +) : GroupKeyEnvelopeStore { + private fun key(chatId: ChatId, ownPk: ByteArray) = chatId.bytes.toList() to ownPk.toList() + + override suspend fun load(chatId: ChatId, ownPublicKey: ByteArray) = rows[key(chatId, ownPublicKey)] + + override suspend fun save(chatId: ChatId, ownPublicKey: ByteArray, envelope: StoredKeyEnvelope) { + rows[key(chatId, ownPublicKey)] = envelope + } + + override suspend fun delete(chatId: ChatId, ownPublicKey: ByteArray) { + rows.remove(key(chatId, ownPublicKey)) + } + + override suspend fun clear() = rows.clear() +}