Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.ReactionSummary
import com.flipcash.services.models.chat.TypingState
import com.getcode.opencode.model.core.ID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

Expand Down Expand Up @@ -49,16 +50,54 @@ interface EventStreamOperations {
}

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

/** Resolves the [ChatId] for an existing DM with [contact]. */
suspend fun getChatId(contact: DeviceContact): Result<ChatId>

/** Resolves the [ChatId] for an existing DM with [userId] (tip chat). */
suspend fun getChatId(userId: ID): Result<ChatId>
}

/**
* Per-chat messaging operations: sending, receiving, and read receipts.
*
* All methods target a single conversation identified by [ChatId]. Resolving
* *which* [ChatId] to operate on is [DmChatResolver]'s job.
*
* Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate].
*/
interface MessagingOperations {
/**
* Returns the other member of a DM (fetching from the server and persisting
* if not cached locally), or `null` if it can't be resolved. Chat-type
* agnostic — the returned [ChatMember] carries the counterparty's
* [com.flipcash.services.models.UserProfile] (display name, avatar) for
* rendering a sender without a phone contact.
*/
suspend fun getOtherMember(chatId: ChatId): ChatMember?

/** Returns the E.164 phone number of the other member in a DM, or `null` if unknown. */
suspend fun getOtherMemberE164(chatId: ChatId): String?

Expand Down Expand Up @@ -104,7 +143,7 @@ interface MessagingOperations {

/**
* Unified facade for the chat subsystem, composing [FeedOperations],
* [EventStreamOperations], and [MessagingOperations].
* [EventStreamOperations], [DmChatResolver], and [MessagingOperations].
*
* The concrete implementation is
* [RealChatCoordinator][com.flipcash.shared.chat.internal.RealChatCoordinator],
Expand All @@ -113,7 +152,7 @@ interface MessagingOperations {
*
* @see com.flipcash.shared.chat.internal.RealChatCoordinator
*/
interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOperations {
interface ChatCoordinator : FeedOperations, EventStreamOperations, DmChatResolver, MessagingOperations {
/** Full observable snapshot of chat state (feed, typing, reactions, active chat). */
val state: StateFlow<ChatState>

Expand All @@ -122,3 +161,4 @@ interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOper
}

class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164")
class FailedToGenerateChatIdException(identifier: String?) : Exception("Failed to generate chat ID for $identifier")
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.flipcash.shared.chat.internal

import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.getcode.opencode.model.core.ID
import java.security.MessageDigest
import javax.inject.Inject

class ChatIdGenerator @Inject constructor() {

/**
* Derives the canonical DM chat id for [chatType] between users [a] and [b],
* mirroring the server's `MustDeriveDmChatID(chatType, a, b)`. Stable and
* order-independent, so either user derives the same id without a lookup.
*/
fun generate(chatType: ChatType, a: ID, b: ID): ChatId =
compute(chatType.dmDomain(), a.toByteArray(), b.toByteArray())

/**
* Domain separator for [this] DM chat type. Contact DMs hash under the bare
* legacy prefix (their ids predate typed derivation and must not change);
* every other DM type appends its `ChatType` enum value. Mirrors
* flipcash2-server `chat/model.go`.
*/
private fun ChatType.dmDomain(): String = when (this) {
ChatType.CONTACT_DM -> DM_DOMAIN
ChatType.TIP_DM -> TIP_DM_DOMAIN
ChatType.UNKNOWN -> error("cannot derive a DM chat id for chat type $this")
}

/**
* Mirrors the server's derivation byte-for-byte: SHA-256 over the [domain]
* followed by the unsigned-lexicographically sorted member set, where a
* self-pair (a == b) collapses to a single member. The server rejects any
* intent whose chat id doesn't match, so this is a wire contract and must
* not diverge.
*/
private fun compute(domain: String, a: ByteArray, b: ByteArray): ChatId {
val (first, second) = if (a.compareUnsigned(b) <= 0) a to b else b to a
val digest = MessageDigest.getInstance("SHA-256").run {
update(domain.toByteArray(Charsets.UTF_8))
update(first)
if (!first.contentEquals(second)) update(second)
digest()
}
return ChatId(digest)
}

/** Lexicographic comparison treating bytes as unsigned, like Solana key ordering. */
private fun ByteArray.compareUnsigned(other: ByteArray): Int {
val shared = minOf(size, other.size)
for (i in 0 until shared) {
val diff = (this[i].toInt() and 0xFF) - (other[i].toInt() and 0xFF)
if (diff != 0) return diff
}
return size - other.size
}

private companion object {
/** Bare legacy domain: contact DM ids predate typed derivation. */
const val DM_DOMAIN = "flipcash:chat:dm"

/** TIP_DM appends its ChatType enum value (2) to the base domain. */
const val TIP_DM_DOMAIN = "$DM_DOMAIN:2"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatState
import com.flipcash.shared.chat.DmChatResolver
import com.flipcash.shared.chat.EventStreamOperations
import com.flipcash.shared.chat.FeedOperations
import com.flipcash.shared.chat.MessagingOperations
import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
import com.getcode.opencode.model.accounts.AccountCluster
import com.getcode.opencode.providers.SessionListener
Expand Down Expand Up @@ -52,6 +54,7 @@ import kotlin.time.Duration.Companion.seconds
* |----------|-----------|----------------|
* | [FeedSyncDelegate] | [FeedOperations] | Feed sync, DB observation, unread counts |
* | [EventStreamDelegate] | [EventStreamOperations] | Event stream, real-time updates, gap-aware sequencing, reactions, typing |
* | [DmChatResolverDelegate] | [DmChatResolver] | Resolve a DM's [ChatId] from its participants (derive or look up) |
* | [MessagingDelegate] | [MessagingOperations] | Per-chat send/receive, read pointers, paging, notifications |
*
* **What lives here (and why):**
Expand All @@ -72,6 +75,7 @@ import kotlin.time.Duration.Companion.seconds
class RealChatCoordinator @Inject constructor(
private val feedDelegate: FeedSyncDelegate,
private val eventStreamDelegate: EventStreamDelegate,
private val dmChatResolverDelegate: DmChatResolverDelegate,
private val messagingDelegate: MessagingDelegate,
private val stateHolder: ChatStateHolder,
private val userManager: UserManager,
Expand All @@ -83,6 +87,7 @@ class RealChatCoordinator @Inject constructor(
DefaultLifecycleObserver,
FeedOperations by feedDelegate,
EventStreamOperations by eventStreamDelegate,
DmChatResolver by dmChatResolverDelegate,
MessagingOperations by messagingDelegate {

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.flipcash.shared.chat.internal.delegates

import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.DmChatResolver
import com.flipcash.shared.chat.FailedToGenerateChatIdException
import com.flipcash.shared.chat.NoDmChatInitializedException
import com.flipcash.shared.chat.internal.ChatIdGenerator
import com.getcode.opencode.model.core.ID
import com.getcode.utils.decodeBase58
import com.getcode.utils.hexEncodedString
import javax.inject.Inject
import javax.inject.Singleton

/**
* Resolves the [ChatId] of a DM from its participants — either by deterministic
* derivation ([generateChatId]) or by looking up an already-initialized chat in
* local persistence ([getChatId]).
*
* This is pure identity resolution: it does not send, receive, or mutate any
* conversation. Operating on a resolved [ChatId] is
* [MessagingDelegate][com.flipcash.shared.chat.internal.delegates.MessagingDelegate]'s job.
*
* @see com.flipcash.shared.chat.internal.RealChatCoordinator
*/
@Singleton
class DmChatResolverDelegate @Inject constructor(
private val chatIdGenerator: ChatIdGenerator,
private val userManager: UserManager,
private val contactDataSource: ContactDataSource,
private val memberDataSource: ChatMemberDataSource,
) : DmChatResolver {

override suspend fun generateChatId(userId: ID): Result<ChatId> {
val self = userManager.accountId ?: return Result.failure(FailedToGenerateChatIdException(null))
return Result.success(chatIdGenerator.generate(ChatType.TIP_DM, self, userId))
}

override suspend fun getChatId(contact: DeviceContact): Result<ChatId> {
val raw = contactDataSource.getDmChatId(contact.e164)
if (raw.isNullOrEmpty()) {
return Result.failure(NoDmChatInitializedException(contact.e164))
}
return runCatching { ChatId(raw.decodeBase58()) }
}

override suspend fun getChatId(userId: ID): Result<ChatId> {
val chatId = memberDataSource.getChatIdForUser(userId, ChatType.TIP_DM)
?: return Result.failure(NoDmChatInitializedException(userId.hexEncodedString()))
return Result.success(chatId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import com.getcode.utils.TraceType
import com.getcode.utils.trace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
Expand Down Expand Up @@ -167,11 +169,15 @@ class FeedSyncDelegate @Inject constructor(
* required (its failure fails the whole sync, preserving prior behaviour); a TIP_DM failure is
* tolerated so tips never break the main DM list. Each chat carries its own [ChatType].
*/
internal suspend fun fetchCombinedFeed(): Result<List<ChatMetadata>> {
val contact = chatController.getDmChatFeed(ChatType.CONTACT_DM)
.getOrElse { return Result.failure(it) }
val tip = chatController.getDmChatFeed(ChatType.TIP_DM).getOrNull()
return Result.success(contact.chats + (tip?.chats ?: emptyList()))
internal suspend fun fetchCombinedFeed(): Result<List<ChatMetadata>> = coroutineScope {
// Fetch both feeds concurrently — total time is the slower of the two, not their sum.
val contactDeferred = async { chatController.getDmChatFeed(ChatType.CONTACT_DM) }
val tipDeferred = async { chatController.getDmChatFeed(ChatType.TIP_DM) }

// Contact feed is required (its failure fails the whole sync); a TIP_DM failure is tolerated.
val contact = contactDeferred.await().getOrElse { return@coroutineScope Result.failure(it) }
val tip = tipDeferred.await().getOrNull()
Result.success(contact.chats + (tip?.chats ?: emptyList()))
}

private suspend fun performFeedSync() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
Expand All @@ -24,10 +22,8 @@ import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingState
import com.flipcash.shared.chat.MessagingOperations
import com.flipcash.shared.chat.NoDmChatInitializedException
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.services.user.UserManager
import com.getcode.utils.decodeBase58
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -58,35 +54,26 @@ class MessagingDelegate @Inject constructor(
private val metadataDataSource: ChatMetadataDataSource,
private val messageDataSource: ChatMessageDataSource,
private val memberDataSource: ChatMemberDataSource,
private val contactDataSource: ContactDataSource,
private val notificationManager: NotificationManagerCompat,
private val userManager: UserManager,
private val stateHolder: ChatStateHolder,
) : MessagingOperations {

// region MessagingOperations

override suspend fun getChatId(contact: DeviceContact): Result<ChatId> {
val raw = contactDataSource.getDmChatId(contact.e164)
if (raw.isNullOrEmpty()) {
return Result.failure(NoDmChatInitializedException(contact.e164))
}
return runCatching { ChatId(raw.decodeBase58()) }
}

override suspend fun getOtherMemberE164(chatId: ChatId): String? {
override suspend fun getOtherMember(chatId: ChatId): ChatMember? {
val selfId = userManager.accountId
val localMembers = memberDataSource.getMembersForChat(chatId)
val otherMember = localMembers.firstOrNull { it.userId != selfId }
if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber
localMembers.firstOrNull { it.userId != selfId }?.let { return it }

val metadata = chatController.getChat(chatId).getOrNull() ?: return null
memberDataSource.upsert(chatId, metadata.members)
return metadata.members
.firstOrNull { it.userId != selfId }
?.userProfile?.verifiedPhoneNumber
return metadata.members.firstOrNull { it.userId != selfId }
}

override suspend fun getOtherMemberE164(chatId: ChatId): String? =
getOtherMember(chatId)?.userProfile?.verifiedPhoneNumber

override fun setActiveChatId(chatId: ChatId?) {
stateHolder.update { it.copy(activeChat = chatId) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.internal.ChatIdGenerator
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.shared.chat.internal.RealChatCoordinator
import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate
import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate
import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate
import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
Expand Down Expand Up @@ -104,15 +106,22 @@ class ChatCoordinatorEagerBalanceTest {
metadataDataSource = metadataDataSource,
messageDataSource = messageDataSource,
memberDataSource = memberDataSource,
contactDataSource = mockk<ContactDataSource>(relaxed = true),
notificationManager = mockk(relaxed = true),
userManager = userManager,
stateHolder = stateHolder,
)

val dmChatResolverDelegate = DmChatResolverDelegate(
chatIdGenerator = ChatIdGenerator(),
userManager = userManager,
contactDataSource = mockk<ContactDataSource>(relaxed = true),
memberDataSource = memberDataSource,
)

coordinator = RealChatCoordinator(
feedDelegate = feedDelegate,
eventStreamDelegate = eventStreamDelegate,
dmChatResolverDelegate = dmChatResolverDelegate,
messagingDelegate = messagingDelegate,
stateHolder = stateHolder,
userManager = userManager,
Expand Down
Loading
Loading