Skip to content

Commit 80461e4

Browse files
authored
feat(chat): canonical DM chat-id derivation, DmChatResolver, concurrent feed sync (#1123)
* feat(chat): derive canonical DM chat IDs matching server Add ChatIdGenerator, which deterministically derives a DM's ChatId from the two participants' user IDs: SHA-256 over a per-type domain ("flipcash:chat:dm" for CONTACT_DM, "flipcash:chat:dm:2" for TIP_DM) and the unsigned-sorted, self-pair-collapsed member set. Mirrors the server's MustDeriveDmChatID byte-for-byte so either side reaches the same id without a prior lookup. Tests pin the wire-contract vectors (matching the iOS TipDmChatIDTests). * refactor(chat): extract DmChatResolver and add member accessors Split DM-identity resolution out of MessagingOperations into a dedicated DmChatResolver interface (generateChatId / getChatId), implemented by a new DmChatResolverDelegate and composed into RealChatCoordinator via `by` delegation. MessagingOperations is left focused on operating on an existing ChatId. Add MessagingOperations.getOtherMember(chatId), returning the full ChatMember (profile incl. avatar) with a local-cache-then-network fallback; getOtherMemberE164 now delegates to it. Back tip DM chat-id resolution with ChatMemberDao.getChatIdForMember / ChatMemberDataSource.getChatIdForUser. * perf(chat): fetch contact and tip DM feeds concurrently Run the two getDmChatFeed calls in parallel via coroutineScope/async so combined-feed latency is max(contact, tip) instead of their sum. Failure semantics are unchanged: a contact-feed failure is fatal, a tip-feed failure is tolerated. ---------
1 parent ac2d8bf commit 80461e4

11 files changed

Lines changed: 328 additions & 32 deletions

File tree

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.flipcash.services.models.chat.MessageContent
1313
import com.flipcash.services.models.chat.MessagePointer
1414
import com.flipcash.services.models.chat.ReactionSummary
1515
import com.flipcash.services.models.chat.TypingState
16+
import com.getcode.opencode.model.core.ID
1617
import kotlinx.coroutines.flow.Flow
1718
import kotlinx.coroutines.flow.StateFlow
1819

@@ -49,16 +50,54 @@ interface EventStreamOperations {
4950
}
5051

5152
/**
52-
* Per-chat messaging operations: sending, receiving, read receipts, and identity.
53+
* Resolves the [ChatId] of a DM from its participants, independent of any single
54+
* conversation. Two ways to arrive at an id:
5355
*
54-
* All methods target a single conversation identified by [ChatId].
56+
* - **Derive** ([generateChatId]) — compute the canonical DM id from the
57+
* participants alone. Deterministic and order-independent, so either user
58+
* reaches the same id without a prior lookup, matching the server's
59+
* `MustDeriveDmChatID`. Works even before a chat has been initialized.
60+
* - **Look up** ([getChatId]) — return the id of an *already-initialized* DM
61+
* from local persistence, failing with [NoDmChatInitializedException] if none
62+
* exists yet.
5563
*
56-
* Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate].
64+
* Only tip DMs are derivable client-side: derivation needs the counterparty's
65+
* `UserId`, which the client has for a tip target but not for a phone [contact]
66+
* (`FlipcashContact` carries no user id). Contact DMs don't need it anyway — the
67+
* server pre-derives their id and delivers it via `GetFlipcashContacts`, so a
68+
* contact's id is always resolved through [getChatId], never derived.
69+
*
70+
* Implemented by [com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate].
5771
*/
58-
interface MessagingOperations {
72+
interface DmChatResolver {
73+
/** Derives the canonical TIP_DM [ChatId] between the current user and [userId]. */
74+
suspend fun generateChatId(userId: ID): Result<ChatId>
75+
5976
/** Resolves the [ChatId] for an existing DM with [contact]. */
6077
suspend fun getChatId(contact: DeviceContact): Result<ChatId>
6178

79+
/** Resolves the [ChatId] for an existing DM with [userId] (tip chat). */
80+
suspend fun getChatId(userId: ID): Result<ChatId>
81+
}
82+
83+
/**
84+
* Per-chat messaging operations: sending, receiving, and read receipts.
85+
*
86+
* All methods target a single conversation identified by [ChatId]. Resolving
87+
* *which* [ChatId] to operate on is [DmChatResolver]'s job.
88+
*
89+
* Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate].
90+
*/
91+
interface MessagingOperations {
92+
/**
93+
* Returns the other member of a DM (fetching from the server and persisting
94+
* if not cached locally), or `null` if it can't be resolved. Chat-type
95+
* agnostic — the returned [ChatMember] carries the counterparty's
96+
* [com.flipcash.services.models.UserProfile] (display name, avatar) for
97+
* rendering a sender without a phone contact.
98+
*/
99+
suspend fun getOtherMember(chatId: ChatId): ChatMember?
100+
62101
/** Returns the E.164 phone number of the other member in a DM, or `null` if unknown. */
63102
suspend fun getOtherMemberE164(chatId: ChatId): String?
64103

@@ -104,7 +143,7 @@ interface MessagingOperations {
104143

105144
/**
106145
* Unified facade for the chat subsystem, composing [FeedOperations],
107-
* [EventStreamOperations], and [MessagingOperations].
146+
* [EventStreamOperations], [DmChatResolver], and [MessagingOperations].
108147
*
109148
* The concrete implementation is
110149
* [RealChatCoordinator][com.flipcash.shared.chat.internal.RealChatCoordinator],
@@ -113,7 +152,7 @@ interface MessagingOperations {
113152
*
114153
* @see com.flipcash.shared.chat.internal.RealChatCoordinator
115154
*/
116-
interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOperations {
155+
interface ChatCoordinator : FeedOperations, EventStreamOperations, DmChatResolver, MessagingOperations {
117156
/** Full observable snapshot of chat state (feed, typing, reactions, active chat). */
118157
val state: StateFlow<ChatState>
119158

@@ -122,3 +161,4 @@ interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOper
122161
}
123162

124163
class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164")
164+
class FailedToGenerateChatIdException(identifier: String?) : Exception("Failed to generate chat ID for $identifier")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.flipcash.shared.chat.internal
2+
3+
import com.flipcash.services.models.chat.ChatId
4+
import com.flipcash.services.models.chat.ChatType
5+
import com.getcode.opencode.model.core.ID
6+
import java.security.MessageDigest
7+
import javax.inject.Inject
8+
9+
class ChatIdGenerator @Inject constructor() {
10+
11+
/**
12+
* Derives the canonical DM chat id for [chatType] between users [a] and [b],
13+
* mirroring the server's `MustDeriveDmChatID(chatType, a, b)`. Stable and
14+
* order-independent, so either user derives the same id without a lookup.
15+
*/
16+
fun generate(chatType: ChatType, a: ID, b: ID): ChatId =
17+
compute(chatType.dmDomain(), a.toByteArray(), b.toByteArray())
18+
19+
/**
20+
* Domain separator for [this] DM chat type. Contact DMs hash under the bare
21+
* legacy prefix (their ids predate typed derivation and must not change);
22+
* every other DM type appends its `ChatType` enum value. Mirrors
23+
* flipcash2-server `chat/model.go`.
24+
*/
25+
private fun ChatType.dmDomain(): String = when (this) {
26+
ChatType.CONTACT_DM -> DM_DOMAIN
27+
ChatType.TIP_DM -> TIP_DM_DOMAIN
28+
ChatType.UNKNOWN -> error("cannot derive a DM chat id for chat type $this")
29+
}
30+
31+
/**
32+
* Mirrors the server's derivation byte-for-byte: SHA-256 over the [domain]
33+
* followed by the unsigned-lexicographically sorted member set, where a
34+
* self-pair (a == b) collapses to a single member. The server rejects any
35+
* intent whose chat id doesn't match, so this is a wire contract and must
36+
* not diverge.
37+
*/
38+
private fun compute(domain: String, a: ByteArray, b: ByteArray): ChatId {
39+
val (first, second) = if (a.compareUnsigned(b) <= 0) a to b else b to a
40+
val digest = MessageDigest.getInstance("SHA-256").run {
41+
update(domain.toByteArray(Charsets.UTF_8))
42+
update(first)
43+
if (!first.contentEquals(second)) update(second)
44+
digest()
45+
}
46+
return ChatId(digest)
47+
}
48+
49+
/** Lexicographic comparison treating bytes as unsigned, like Solana key ordering. */
50+
private fun ByteArray.compareUnsigned(other: ByteArray): Int {
51+
val shared = minOf(size, other.size)
52+
for (i in 0 until shared) {
53+
val diff = (this[i].toInt() and 0xFF) - (other[i].toInt() and 0xFF)
54+
if (diff != 0) return diff
55+
}
56+
return size - other.size
57+
}
58+
59+
private companion object {
60+
/** Bare legacy domain: contact DM ids predate typed derivation. */
61+
const val DM_DOMAIN = "flipcash:chat:dm"
62+
63+
/** TIP_DM appends its ChatType enum value (2) to the base domain. */
64+
const val TIP_DM_DOMAIN = "$DM_DOMAIN:2"
65+
}
66+
}

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import com.flipcash.services.models.chat.ChatId
1212
import com.flipcash.services.user.UserManager
1313
import com.flipcash.shared.chat.ChatCoordinator
1414
import com.flipcash.shared.chat.ChatState
15+
import com.flipcash.shared.chat.DmChatResolver
1516
import com.flipcash.shared.chat.EventStreamOperations
1617
import com.flipcash.shared.chat.FeedOperations
1718
import com.flipcash.shared.chat.MessagingOperations
1819
import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate
1920
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
21+
import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate
2022
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
2123
import com.getcode.opencode.model.accounts.AccountCluster
2224
import com.getcode.opencode.providers.SessionListener
@@ -52,6 +54,7 @@ import kotlin.time.Duration.Companion.seconds
5254
* |----------|-----------|----------------|
5355
* | [FeedSyncDelegate] | [FeedOperations] | Feed sync, DB observation, unread counts |
5456
* | [EventStreamDelegate] | [EventStreamOperations] | Event stream, real-time updates, gap-aware sequencing, reactions, typing |
57+
* | [DmChatResolverDelegate] | [DmChatResolver] | Resolve a DM's [ChatId] from its participants (derive or look up) |
5558
* | [MessagingDelegate] | [MessagingOperations] | Per-chat send/receive, read pointers, paging, notifications |
5659
*
5760
* **What lives here (and why):**
@@ -72,6 +75,7 @@ import kotlin.time.Duration.Companion.seconds
7275
class RealChatCoordinator @Inject constructor(
7376
private val feedDelegate: FeedSyncDelegate,
7477
private val eventStreamDelegate: EventStreamDelegate,
78+
private val dmChatResolverDelegate: DmChatResolverDelegate,
7579
private val messagingDelegate: MessagingDelegate,
7680
private val stateHolder: ChatStateHolder,
7781
private val userManager: UserManager,
@@ -83,6 +87,7 @@ class RealChatCoordinator @Inject constructor(
8387
DefaultLifecycleObserver,
8488
FeedOperations by feedDelegate,
8589
EventStreamOperations by eventStreamDelegate,
90+
DmChatResolver by dmChatResolverDelegate,
8691
MessagingOperations by messagingDelegate {
8792

8893
companion object {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.flipcash.shared.chat.internal.delegates
2+
3+
import com.flipcash.app.core.contacts.DeviceContact
4+
import com.flipcash.app.persistence.sources.ChatMemberDataSource
5+
import com.flipcash.app.persistence.sources.ContactDataSource
6+
import com.flipcash.services.models.chat.ChatId
7+
import com.flipcash.services.models.chat.ChatType
8+
import com.flipcash.services.user.UserManager
9+
import com.flipcash.shared.chat.DmChatResolver
10+
import com.flipcash.shared.chat.FailedToGenerateChatIdException
11+
import com.flipcash.shared.chat.NoDmChatInitializedException
12+
import com.flipcash.shared.chat.internal.ChatIdGenerator
13+
import com.getcode.opencode.model.core.ID
14+
import com.getcode.utils.decodeBase58
15+
import com.getcode.utils.hexEncodedString
16+
import javax.inject.Inject
17+
import javax.inject.Singleton
18+
19+
/**
20+
* Resolves the [ChatId] of a DM from its participants — either by deterministic
21+
* derivation ([generateChatId]) or by looking up an already-initialized chat in
22+
* local persistence ([getChatId]).
23+
*
24+
* This is pure identity resolution: it does not send, receive, or mutate any
25+
* conversation. Operating on a resolved [ChatId] is
26+
* [MessagingDelegate][com.flipcash.shared.chat.internal.delegates.MessagingDelegate]'s job.
27+
*
28+
* @see com.flipcash.shared.chat.internal.RealChatCoordinator
29+
*/
30+
@Singleton
31+
class DmChatResolverDelegate @Inject constructor(
32+
private val chatIdGenerator: ChatIdGenerator,
33+
private val userManager: UserManager,
34+
private val contactDataSource: ContactDataSource,
35+
private val memberDataSource: ChatMemberDataSource,
36+
) : DmChatResolver {
37+
38+
override suspend fun generateChatId(userId: ID): Result<ChatId> {
39+
val self = userManager.accountId ?: return Result.failure(FailedToGenerateChatIdException(null))
40+
return Result.success(chatIdGenerator.generate(ChatType.TIP_DM, self, userId))
41+
}
42+
43+
override suspend fun getChatId(contact: DeviceContact): Result<ChatId> {
44+
val raw = contactDataSource.getDmChatId(contact.e164)
45+
if (raw.isNullOrEmpty()) {
46+
return Result.failure(NoDmChatInitializedException(contact.e164))
47+
}
48+
return runCatching { ChatId(raw.decodeBase58()) }
49+
}
50+
51+
override suspend fun getChatId(userId: ID): Result<ChatId> {
52+
val chatId = memberDataSource.getChatIdForUser(userId, ChatType.TIP_DM)
53+
?: return Result.failure(NoDmChatInitializedException(userId.hexEncodedString()))
54+
return Result.success(chatId)
55+
}
56+
}

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import com.getcode.utils.TraceType
1919
import com.getcode.utils.trace
2020
import kotlinx.coroutines.CoroutineScope
2121
import kotlinx.coroutines.Job
22+
import kotlinx.coroutines.async
2223
import kotlinx.coroutines.channels.Channel
24+
import kotlinx.coroutines.coroutineScope
2325
import kotlinx.coroutines.flow.Flow
2426
import kotlinx.coroutines.flow.combine
2527
import kotlinx.coroutines.flow.launchIn
@@ -167,11 +169,15 @@ class FeedSyncDelegate @Inject constructor(
167169
* required (its failure fails the whole sync, preserving prior behaviour); a TIP_DM failure is
168170
* tolerated so tips never break the main DM list. Each chat carries its own [ChatType].
169171
*/
170-
internal suspend fun fetchCombinedFeed(): Result<List<ChatMetadata>> {
171-
val contact = chatController.getDmChatFeed(ChatType.CONTACT_DM)
172-
.getOrElse { return Result.failure(it) }
173-
val tip = chatController.getDmChatFeed(ChatType.TIP_DM).getOrNull()
174-
return Result.success(contact.chats + (tip?.chats ?: emptyList()))
172+
internal suspend fun fetchCombinedFeed(): Result<List<ChatMetadata>> = coroutineScope {
173+
// Fetch both feeds concurrently — total time is the slower of the two, not their sum.
174+
val contactDeferred = async { chatController.getDmChatFeed(ChatType.CONTACT_DM) }
175+
val tipDeferred = async { chatController.getDmChatFeed(ChatType.TIP_DM) }
176+
177+
// Contact feed is required (its failure fails the whole sync); a TIP_DM failure is tolerated.
178+
val contact = contactDeferred.await().getOrElse { return@coroutineScope Result.failure(it) }
179+
val tip = tipDeferred.await().getOrNull()
180+
Result.success(contact.chats + (tip?.chats ?: emptyList()))
175181
}
176182

177183
private suspend fun performFeedSync() {

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ import androidx.paging.Pager
88
import androidx.paging.PagingConfig
99
import androidx.paging.PagingData
1010
import androidx.paging.map
11-
import com.flipcash.app.core.contacts.DeviceContact
1211
import com.flipcash.app.persistence.sources.ChatMemberDataSource
1312
import com.flipcash.app.persistence.sources.ChatMessageDataSource
1413
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
15-
import com.flipcash.app.persistence.sources.ContactDataSource
1614
import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator
1715
import com.flipcash.services.controllers.ChatController
1816
import com.flipcash.services.controllers.ChatMessagingController
@@ -24,10 +22,8 @@ import com.flipcash.services.models.chat.MessagePointer
2422
import com.flipcash.services.models.chat.PointerType
2523
import com.flipcash.services.models.chat.TypingState
2624
import com.flipcash.shared.chat.MessagingOperations
27-
import com.flipcash.shared.chat.NoDmChatInitializedException
2825
import com.flipcash.shared.chat.internal.ChatStateHolder
2926
import com.flipcash.services.user.UserManager
30-
import com.getcode.utils.decodeBase58
3127
import kotlinx.coroutines.flow.Flow
3228
import kotlinx.coroutines.flow.distinctUntilChanged
3329
import kotlinx.coroutines.flow.map
@@ -58,35 +54,26 @@ class MessagingDelegate @Inject constructor(
5854
private val metadataDataSource: ChatMetadataDataSource,
5955
private val messageDataSource: ChatMessageDataSource,
6056
private val memberDataSource: ChatMemberDataSource,
61-
private val contactDataSource: ContactDataSource,
6257
private val notificationManager: NotificationManagerCompat,
6358
private val userManager: UserManager,
6459
private val stateHolder: ChatStateHolder,
6560
) : MessagingOperations {
6661

6762
// region MessagingOperations
6863

69-
override suspend fun getChatId(contact: DeviceContact): Result<ChatId> {
70-
val raw = contactDataSource.getDmChatId(contact.e164)
71-
if (raw.isNullOrEmpty()) {
72-
return Result.failure(NoDmChatInitializedException(contact.e164))
73-
}
74-
return runCatching { ChatId(raw.decodeBase58()) }
75-
}
76-
77-
override suspend fun getOtherMemberE164(chatId: ChatId): String? {
64+
override suspend fun getOtherMember(chatId: ChatId): ChatMember? {
7865
val selfId = userManager.accountId
7966
val localMembers = memberDataSource.getMembersForChat(chatId)
80-
val otherMember = localMembers.firstOrNull { it.userId != selfId }
81-
if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber
67+
localMembers.firstOrNull { it.userId != selfId }?.let { return it }
8268

8369
val metadata = chatController.getChat(chatId).getOrNull() ?: return null
8470
memberDataSource.upsert(chatId, metadata.members)
85-
return metadata.members
86-
.firstOrNull { it.userId != selfId }
87-
?.userProfile?.verifiedPhoneNumber
71+
return metadata.members.firstOrNull { it.userId != selfId }
8872
}
8973

74+
override suspend fun getOtherMemberE164(chatId: ChatId): String? =
75+
getOtherMember(chatId)?.userProfile?.verifiedPhoneNumber
76+
9077
override fun setActiveChatId(chatId: ChatId?) {
9178
stateHolder.update { it.copy(activeChat = chatId) }
9279
}

‎apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import com.flipcash.services.models.chat.ChatId
1414
import com.flipcash.services.models.chat.ChatMessage
1515
import com.flipcash.services.models.chat.ChatUpdate
1616
import com.flipcash.services.models.chat.MessageContent
17+
import com.flipcash.shared.chat.internal.ChatIdGenerator
1718
import com.flipcash.shared.chat.internal.ChatStateHolder
1819
import com.flipcash.shared.chat.internal.RealChatCoordinator
1920
import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate
2021
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
22+
import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate
2123
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
2224
import com.getcode.opencode.model.financial.CurrencyCode
2325
import com.getcode.opencode.model.financial.Fiat
@@ -104,15 +106,22 @@ class ChatCoordinatorEagerBalanceTest {
104106
metadataDataSource = metadataDataSource,
105107
messageDataSource = messageDataSource,
106108
memberDataSource = memberDataSource,
107-
contactDataSource = mockk<ContactDataSource>(relaxed = true),
108109
notificationManager = mockk(relaxed = true),
109110
userManager = userManager,
110111
stateHolder = stateHolder,
111112
)
112113

114+
val dmChatResolverDelegate = DmChatResolverDelegate(
115+
chatIdGenerator = ChatIdGenerator(),
116+
userManager = userManager,
117+
contactDataSource = mockk<ContactDataSource>(relaxed = true),
118+
memberDataSource = memberDataSource,
119+
)
120+
113121
coordinator = RealChatCoordinator(
114122
feedDelegate = feedDelegate,
115123
eventStreamDelegate = eventStreamDelegate,
124+
dmChatResolverDelegate = dmChatResolverDelegate,
116125
messagingDelegate = messagingDelegate,
117126
stateHolder = stateHolder,
118127
userManager = userManager,

0 commit comments

Comments
 (0)