diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts new file mode 100644 index 000000000..929b40ef5 --- /dev/null +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.chat" +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + + implementation(libs.bundles.kotlinx.serialization) + + implementation(project(":apps:flipcash:shared:persistence:sources")) + implementation(project(":apps:flipcash:shared:persistence:db")) + implementation(project(":services:flipcash")) + implementation(project(":libs:network:connectivity:public")) + implementation(libs.androidx.lifecycle.process) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt new file mode 100644 index 000000000..c237f72ab --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -0,0 +1,327 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.flipcash.shared.chat + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +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.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingNotification +import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.providers.SessionListener +import com.getcode.utils.TraceType +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +@Singleton +class ChatCoordinator @Inject constructor( + private val chatController: ChatController, + private val messagingController: ChatMessagingController, + private val eventStreamingController: EventStreamingController, + private val metadataDataSource: ChatMetadataDataSource, + private val messageDataSource: ChatMessageDataSource, + private val memberDataSource: ChatMemberDataSource, + private val networkObserver: NetworkConnectivityListener, + private val userManager: UserManager, +) : SessionListener, DefaultLifecycleObserver { + + companion object { + private const val TAG = "ChatCoordinator" + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cluster = MutableStateFlow(null) + private val _state = MutableStateFlow(ChatState()) + + private var syncJob: Job? = null + private var eventStreamCollectJob: Job? = null + + val state: StateFlow + get() = _state.asStateFlow() + + val feed: Flow> + get() = _state.map { state -> + state.feed.map { metadata -> + val readPointer = metadata.members + .firstOrNull { it.userId == userManager.accountId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value ?: 0L + + val unreadCount = metadata.lastMessage?.let { lastMsg -> + if (lastMsg.messageId > readPointer) 1 else 0 + } ?: 0 + + ChatSummary(metadata = metadata, unreadCount = unreadCount) + } + } + + // region SessionListener + + override suspend fun onUserLoggedIn(cluster: AccountCluster) { + trace(tag = TAG, message = "User logged in, hydrating chat", type = TraceType.User) + this.cluster.value = cluster + hydrateFromPersistence() + } + + // endregion + + // region Lifecycle + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + cluster.filterNotNull() + .flatMapLatest { networkObserver.state } + .distinctUntilChanged() + .filter { it.connected } + .debounce(1.seconds) + .onEach { + trace(tag = TAG, message = "Network connected, re-syncing chat feed", type = TraceType.Process) + syncFeed() + openEventStream() + } + .launchIn(scope) + } + + override fun onStart(owner: LifecycleOwner) { + if (cluster.value != null) { + trace(tag = TAG, message = "Lifecycle resumed, syncing chat feed", type = TraceType.Process) + syncFeed() + openEventStream() + } + } + + override fun onStop(owner: LifecycleOwner) { + closeEventStream() + } + + // endregion + + // region Public API + + fun observeMessages(chatId: ChatId): Flow> { + return messageDataSource.observeMessages(chatId) + } + + fun observeTypingIndicators(chatId: ChatId): Flow> { + return _state.map { it.typingIndicators[chatId] ?: emptySet() } + } + + suspend fun loadMessages(chatId: ChatId, limit: Int = 100) { + messagingController.getMessages(chatId) + .onSuccess { messages -> + messageDataSource.upsert(chatId, messages) + } + } + + suspend fun sendMessage(chatId: ChatId, content: List): Result { + val senderId = userManager.accountId + ?: return Result.failure(IllegalStateException("Cannot send message without an account")) + + val (_, clientMessageId) = messageDataSource.insertPending( + chatId = chatId, + content = content, + senderId = senderId, + ) + + return messagingController.sendMessage(chatId, content, clientMessageId) + .onSuccess { serverMessage -> + messageDataSource.confirmPending(chatId, clientMessageId, serverMessage.messageId) + } + .onFailure { + messageDataSource.failPending(chatId, clientMessageId) + } + } + + suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result { + return messagingController.advancePointer(chatId, PointerType.READ, messageId) + } + + suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result { + return messagingController.notifyIsTyping(chatId, typingState) + } + + suspend fun reset() { + closeEventStream() + syncJob?.cancel() + _state.value = ChatState() + cluster.value = null + metadataDataSource.clear() + messageDataSource.clear() + memberDataSource.clear() + trace(tag = TAG, message = "reset complete", type = TraceType.Process) + } + + // endregion + + // region Internal + + private suspend fun hydrateFromPersistence() { + val entities = metadataDataSource.observeAll().firstOrNull() ?: return + if (entities.isEmpty()) return + + val feed = entities.map { entity -> + val members = memberDataSource.getMembersForChat(entity.chatIdHex) + val lastMessage = entity.lastMessageId?.let { + messageDataSource.getLatest(entity.chatIdHex) + } + metadataDataSource.toMetadata(entity, members, lastMessage) + } + + _state.update { it.copy(feed = feed) } + trace(tag = TAG, message = "Hydrated ${feed.size} chats from persistence", type = TraceType.Process) + } + + private fun syncFeed() { + syncJob?.cancel() + syncJob = scope.launch { performFeedSync() } + } + + private suspend fun performFeedSync() { + _state.update { it.copy(feedSyncState = FeedSyncState.Syncing) } + chatController.getDmChatFeed() + .onSuccess { page -> + metadataDataSource.upsert(page.chats) + + for (chat in page.chats) { + memberDataSource.upsert(chat.chatId, chat.members) + } + + _state.update { it.copy(feed = page.chats, feedSyncState = FeedSyncState.Synced) } + trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) + } + .onFailure { error -> + _state.update { it.copy(feedSyncState = FeedSyncState.Error) } + trace(tag = TAG, message = "Feed sync failed: ${error.message}", type = TraceType.Error) + } + } + + private fun openEventStream() { + eventStreamingController.open(scope) + eventStreamCollectJob?.cancel() + eventStreamCollectJob = scope.launch { + eventStreamingController.chatUpdates.collect { applyUpdate(it) } + } + } + + private fun closeEventStream() { + eventStreamCollectJob?.cancel() + eventStreamCollectJob = null + eventStreamingController.close() + } + + private suspend fun applyUpdate(update: ChatUpdate) { + val chatId = update.chatId + + // New messages + if (update.newMessages.isNotEmpty()) { + messageDataSource.upsert(chatId, update.newMessages) + + val lastMsg = update.newMessages.maxByOrNull { it.messageId } + if (lastMsg != null) { + metadataDataSource.updateLastMessageId(chatId, lastMsg.messageId) + metadataDataSource.updateLastActivity(chatId, lastMsg.timestamp.toEpochMilliseconds()) + } + } + + // Pointer updates + for (pointer in update.pointerUpdates) { + memberDataSource.updatePointers(chatId, pointer) + } + + // Typing notifications (ephemeral, in-memory only) + if (update.typingNotifications.isNotEmpty()) { + _state.update { state -> + val currentTypists = state.typingIndicators[chatId]?.toMutableSet() ?: mutableSetOf() + for (notification in update.typingNotifications) { + applyTypingNotification(currentTypists, notification) + } + state.copy( + typingIndicators = state.typingIndicators + (chatId to currentTypists.toSet()) + ) + } + } + + // Metadata updates + for (metaUpdate in update.metadataUpdates) { + when (metaUpdate) { + is MetadataUpdate.FullRefresh -> { + metadataDataSource.upsert(metaUpdate.metadata) + memberDataSource.deleteForChat(metaUpdate.metadata.chatId) + memberDataSource.upsert(metaUpdate.metadata.chatId, metaUpdate.metadata.members) + + _state.update { state -> + val updatedFeed = state.feed.map { + if (it.chatId == metaUpdate.metadata.chatId) metaUpdate.metadata else it + } + state.copy(feed = updatedFeed) + } + } + + is MetadataUpdate.LastActivityChanged -> { + metadataDataSource.updateLastActivity( + chatId, + metaUpdate.newLastActivity.toEpochMilliseconds(), + ) + } + } + } + } + + private fun applyTypingNotification( + typists: MutableSet, + notification: TypingNotification, + ) { + when (notification.state) { + TypingState.STARTED_TYPING, TypingState.STILL_TYPING -> { + typists.removeAll { it.userId == notification.userId } + typists.add(ActiveTypist(userId = notification.userId, since = Clock.System.now())) + } + TypingState.STOPPED_TYPING, TypingState.TYPING_TIMED_OUT -> { + typists.removeAll { it.userId == notification.userId } + } + TypingState.UNKNOWN -> Unit + } + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt new file mode 100644 index 000000000..e07850d72 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -0,0 +1,29 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class ChatState( + val feed: List = emptyList(), + val typingIndicators: Map> = emptyMap(), + val feedSyncState: FeedSyncState = FeedSyncState.Idle, +) + +data class ChatSummary( + val metadata: ChatMetadata, + val unreadCount: Int, +) + +data class ActiveTypist( + val userId: ID, + val since: Instant, +) + +enum class FeedSyncState { + Idle, + Syncing, + Synced, + Error, +} 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 new file mode 100644 index 000000000..53c1d1d01 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -0,0 +1,20 @@ +package com.flipcash.shared.chat.inject + +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.providers.SessionListener +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +abstract class ChatModule { + + @Binds + @IntoSet + abstract fun bindSessionListener( + coordinator: ChatCoordinator + ): SessionListener +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/19.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/19.json new file mode 100644 index 000000000..753b9c5c4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/19.json @@ -0,0 +1,623 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "fe77cdeb529ebd81b43cde6296ccd7a6", + "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', 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'" + } + ], + "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, `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": "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 '', 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": "''" + } + ], + "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, 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" + } + ], + "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, 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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `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": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_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, 'fe77cdeb529ebd81b43cde6296ccd7a6')" + ] + } +} \ 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 e8cfd71f7..d71576082 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 @@ -11,11 +11,18 @@ import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters +import com.flipcash.app.persistence.dao.ChatMemberDao +import com.flipcash.app.persistence.dao.ChatMessageDao +import com.flipcash.app.persistence.dao.ChatMetadataDao import com.flipcash.app.persistence.dao.ContactDao import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.entities.ContactMappingEntity import com.flipcash.app.persistence.entities.ContactSyncStateEntity import com.flipcash.app.persistence.entities.CurrencyCreatorDraftEntity @@ -37,6 +44,9 @@ import com.getcode.utils.subByteArray CurrencyCreatorDraftEntity::class, ContactSyncStateEntity::class, ContactMappingEntity::class, + ChatMetadataEntity::class, + ChatMessageEntity::class, + ChatMemberEntity::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class), @@ -56,16 +66,20 @@ import com.getcode.utils.subByteArray AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), ], - version = 18, + version = 19, ) -@TypeConverters(TokenTypeConverters::class) +@TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun tokenDao(): TokenDao abstract fun currencyCreatorDraftDao(): CurrencyCreatorDraftDao abstract fun contactDao(): ContactDao + abstract fun chatMetadataDao(): ChatMetadataDao + abstract fun chatMessageDao(): ChatMessageDao + abstract fun chatMemberDao(): ChatMemberDao 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/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt new file mode 100644 index 000000000..dda15cd8c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -0,0 +1,107 @@ +package com.flipcash.app.persistence.converters + +import androidx.room.TypeConverter +import com.flipcash.app.persistence.entities.MessageStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +class ChatTypeConverters { + + // region MessageContent + + @TypeConverter + fun fromMessageContentList(value: String?): List? { + return value?.let { json.decodeFromString>(it) } + } + + @TypeConverter + fun toMessageContentList(content: List?): String? { + return content?.let { json.encodeToString(it) } + } + + // endregion + + // region MessagePointer + + @TypeConverter + fun fromMessagePointerList(value: String?): List? { + return value?.let { json.decodeFromString>(it) } + } + + @TypeConverter + fun toMessagePointerList(pointers: List?): String? { + return pointers?.let { json.encodeToString(it) } + } + + // endregion + + // region MessageStatus + + @TypeConverter + fun fromMessageStatus(status: MessageStatus): String = status.name + + @TypeConverter + fun toMessageStatus(value: String): MessageStatus = + MessageStatus.entries.firstOrNull { it.name == value } ?: MessageStatus.SENT + + // endregion + + // region UserProfile + + @TypeConverter + fun fromUserProfile(value: String?): UserProfileSerialized? { + return value?.let { json.decodeFromString(it) } + } + + @TypeConverter + fun toUserProfile(profile: UserProfileSerialized?): String? { + return profile?.let { json.encodeToString(it) } + } + + // endregion +} + +@Serializable +sealed interface MessageContentSerialized { + @Serializable + @SerialName("text") + data class Text(val text: String) : MessageContentSerialized +} + +@Serializable +data class MessagePointerSerialized( + val type: String, + val userIdHex: String, + val value: Long, +) + +@Serializable +data class UserProfileSerialized( + val displayName: String?, + val socialAccounts: List, + val verifiedPhoneNumber: String?, + val verifiedEmailAddress: String?, +) + +@Serializable +sealed interface SocialAccountSerialized { + val id: String + + @Serializable + @SerialName("twitter_x") + data class TwitterX( + override val id: String, + val username: String, + val name: String, + val description: String, + val profilePicUrl: String, + val verifiedType: String?, + val followerCount: Int, + ) : SocialAccountSerialized +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt new file mode 100644 index 000000000..25d3d4e36 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -0,0 +1,33 @@ +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.ChatMemberEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatMemberDao { + + @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex") + suspend fun getMembersForChat(chatIdHex: String): List + + @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex") + fun observeMembersForChat(chatIdHex: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: ChatMemberEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entities: List) + + @Query("UPDATE chat_members SET pointers_json = :pointersJson WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex") + suspend fun updatePointers(chatIdHex: String, userIdHex: String, pointersJson: String) + + @Query("DELETE FROM chat_members WHERE chat_id_hex = :chatIdHex") + suspend fun deleteForChat(chatIdHex: String) + + @Query("DELETE FROM chat_members") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt new file mode 100644 index 000000000..1de1674a6 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -0,0 +1,41 @@ +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.ChatMessageEntity +import com.flipcash.app.persistence.entities.MessageStatus +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatMessageDao { + + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC") + fun observeMessages(chatIdHex: String): Flow> + + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC LIMIT 1") + suspend fun getLatest(chatIdHex: String): ChatMessageEntity? + + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex LIMIT 1") + suspend fun getByClientId(chatIdHex: String, clientIdHex: String): ChatMessageEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: ChatMessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entities: List) + + @Query( + """UPDATE chat_messages + SET message_id = :serverMessageId, pending_client_id_hex = NULL, status = 'SENT' + WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex""" + ) + suspend fun confirmPendingMessage(chatIdHex: String, clientIdHex: String, serverMessageId: Long) + + @Query("UPDATE chat_messages SET status = :status WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex") + suspend fun updatePendingStatus(chatIdHex: String, clientIdHex: String, status: MessageStatus) + + @Query("DELETE FROM chat_messages") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt new file mode 100644 index 000000000..325c4b64d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -0,0 +1,33 @@ +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.ChatMetadataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatMetadataDao { + + @Query("SELECT * FROM chat_metadata ORDER BY last_activity_epoch_ms DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getById(chatIdHex: String): ChatMetadataEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: ChatMetadataEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entities: List) + + @Query("UPDATE chat_metadata SET last_activity_epoch_ms = :epochMs WHERE chat_id_hex = :chatIdHex") + suspend fun updateLastActivity(chatIdHex: String, epochMs: Long) + + @Query("UPDATE chat_metadata SET last_message_id = :messageId WHERE chat_id_hex = :chatIdHex") + suspend fun updateLastMessageId(chatIdHex: String, messageId: Long) + + @Query("DELETE FROM chat_metadata") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt new file mode 100644 index 000000000..cb1cce82c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMemberEntity.kt @@ -0,0 +1,17 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.converters.UserProfileSerialized + +@Entity( + tableName = "chat_members", + primaryKeys = ["chat_id_hex", "user_id_hex"], +) +data class ChatMemberEntity( + @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, + @ColumnInfo(name = "user_id_hex") val userIdHex: String, + @ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?, + @ColumnInfo(name = "pointers_json") val pointersJson: List?, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt new file mode 100644 index 000000000..5608f9afd --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.flipcash.app.persistence.converters.MessageContentSerialized + +enum class MessageStatus { + SENDING, + SENT, + FAILED, +} + +@Entity( + tableName = "chat_messages", + primaryKeys = ["chat_id_hex", "message_id"], +) +data class ChatMessageEntity( + @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, + @ColumnInfo(name = "message_id") val messageId: Long, + @ColumnInfo(name = "sender_id_hex") val senderIdHex: String?, + @ColumnInfo(name = "content_json") val contentJson: List?, + @ColumnInfo(name = "timestamp_epoch_ms") val timestampEpochMs: Long, + @ColumnInfo(name = "unread_seq") val unreadSeq: Long, + @ColumnInfo(name = "status", defaultValue = "SENT") val status: MessageStatus = MessageStatus.SENT, + @ColumnInfo(name = "pending_client_id_hex") val pendingClientIdHex: String? = null, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt new file mode 100644 index 000000000..f2b25f2b4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -0,0 +1,13 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "chat_metadata") +data class ChatMetadataEntity( + @PrimaryKey @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, + @ColumnInfo(name = "chat_type") val chatType: String, + @ColumnInfo(name = "last_activity_epoch_ms", index = true) val lastActivityEpochMs: Long, + @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, +) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt new file mode 100644 index 000000000..1987d9068 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.MessagePointer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatMemberDataSource @Inject constructor( + private val mapper: ChatEntityMapper, +) { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeMembers(chatId: ChatId): Flow> = + db?.chatMemberDao()?.observeMembersForChat(mapper.chatIdHex(chatId))?.map { entities -> + entities.map { mapper.toMember(it) } + } ?: emptyFlow() + + suspend fun getMembersForChat(chatIdHex: String): List = + db?.chatMemberDao()?.getMembersForChat(chatIdHex)?.map { mapper.toMember(it) } ?: emptyList() + + suspend fun upsert(chatId: ChatId, members: List) { + val hex = mapper.chatIdHex(chatId) + db?.chatMemberDao()?.upsert(members.map { mapper.toEntity(hex, it) }) + } + + suspend fun updatePointers(chatId: ChatId, pointer: MessagePointer) { + db?.chatMemberDao()?.updatePointers( + mapper.chatIdHex(chatId), + mapper.userIdHex(pointer.userId), + mapper.pointerToJson(pointer), + ) + } + + suspend fun deleteForChat(chatId: ChatId) { + db?.chatMemberDao()?.deleteForChat(mapper.chatIdHex(chatId)) + } + + suspend fun clear() { + db?.chatMemberDao()?.deleteAll() + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt new file mode 100644 index 000000000..19540c119 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -0,0 +1,82 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.RandomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +data class PendingMessage( + val message: ChatMessage, + val clientMessageId: ClientMessageId, +) + +@Singleton +class ChatMessageDataSource @Inject constructor( + private val mapper: ChatEntityMapper, +) { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeMessages(chatId: ChatId): Flow> = + db?.chatMessageDao()?.observeMessages(mapper.chatIdHex(chatId))?.map { entities -> + entities.map { mapper.toMessage(it) } + } ?: emptyFlow() + + suspend fun getLatest(chatIdHex: String): ChatMessage? = + db?.chatMessageDao()?.getLatest(chatIdHex)?.let { mapper.toMessage(it) } + + suspend fun upsert(chatId: ChatId, messages: List) { + val hex = mapper.chatIdHex(chatId) + db?.chatMessageDao()?.upsert(messages.map { mapper.toEntity(hex, it) }) + } + + suspend fun insertPending( + chatId: ChatId, + content: List, + senderId: ID, + ): PendingMessage { + val clientMessageId = ClientMessageId(RandomId.toByteArray()) + val entity = mapper.toPendingEntity( + chatIdHex = mapper.chatIdHex(chatId), + content = content, + senderId = senderId, + clientMessageId = clientMessageId, + ) + db?.chatMessageDao()?.upsert(entity) + return PendingMessage( + message = mapper.toMessage(entity), + clientMessageId = clientMessageId, + ) + } + + suspend fun confirmPending(chatId: ChatId, clientMessageId: ClientMessageId, serverMessageId: Long) { + db?.chatMessageDao()?.confirmPendingMessage( + mapper.chatIdHex(chatId), + mapper.clientMessageIdHex(clientMessageId), + serverMessageId, + ) + } + + suspend fun failPending(chatId: ChatId, clientMessageId: ClientMessageId) { + db?.chatMessageDao()?.updatePendingStatus( + mapper.chatIdHex(chatId), + mapper.clientMessageIdHex(clientMessageId), + MessageStatus.FAILED, + ) + } + + suspend fun clear() { + db?.chatMessageDao()?.deleteAll() + } +} 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 new file mode 100644 index 000000000..0f6c46575 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -0,0 +1,52 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatMetadataDataSource @Inject constructor( + private val mapper: ChatEntityMapper, +) { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeAll(): Flow> = + db?.chatMetadataDao()?.observeAll() ?: emptyFlow() + + suspend fun upsert(metadata: ChatMetadata) { + db?.chatMetadataDao()?.upsert(mapper.toEntity(metadata)) + } + + suspend fun upsert(metadatas: List) { + db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) }) + } + + suspend fun updateLastActivity(chatId: ChatId, epochMs: Long) { + db?.chatMetadataDao()?.updateLastActivity(mapper.chatIdHex(chatId), epochMs) + } + + suspend fun updateLastMessageId(chatId: ChatId, messageId: Long) { + db?.chatMetadataDao()?.updateLastMessageId(mapper.chatIdHex(chatId), messageId) + } + + fun toMetadata( + entity: ChatMetadataEntity, + members: List, + lastMessage: ChatMessage?, + ): ChatMetadata = mapper.toMetadata(entity, members, lastMessage) + + suspend fun clear() { + db?.chatMetadataDao()?.deleteAll() + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt new file mode 100644 index 000000000..edbac5c56 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -0,0 +1,235 @@ +package com.flipcash.app.persistence.sources.mapper.chat + +import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.converters.SocialAccountSerialized +import com.flipcash.app.persistence.converters.UserProfileSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ClientMessageId +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +@Singleton +class ChatEntityMapper @Inject constructor() { + + // region ChatMetadata + + fun toEntity(metadata: ChatMetadata): ChatMetadataEntity { + return ChatMetadataEntity( + chatIdHex = metadata.chatId.bytes.toList().hexEncodedString(), + chatType = metadata.type.name, + lastActivityEpochMs = metadata.lastActivity.toEpochMilliseconds(), + lastMessageId = metadata.lastMessage?.messageId, + ) + } + + fun toMetadata( + entity: ChatMetadataEntity, + members: List, + lastMessage: ChatMessage?, + ): ChatMetadata { + return ChatMetadata( + chatId = chatIdFromHex(entity.chatIdHex), + type = ChatType.entries.firstOrNull { it.name == entity.chatType } ?: ChatType.UNKNOWN, + members = members, + lastMessage = lastMessage, + lastActivity = Instant.fromEpochMilliseconds(entity.lastActivityEpochMs), + ) + } + + // endregion + + // region ChatMessage + + fun toEntity(chatIdHex: String, message: ChatMessage): ChatMessageEntity { + return ChatMessageEntity( + chatIdHex = chatIdHex, + messageId = message.messageId, + senderIdHex = message.senderId?.hexEncodedString(), + contentJson = message.content.map { it.toSerialized() }, + timestampEpochMs = message.timestamp.toEpochMilliseconds(), + unreadSeq = message.unreadSeq, + ) + } + + fun toMessage(entity: ChatMessageEntity): ChatMessage { + return ChatMessage( + messageId = entity.messageId, + senderId = entity.senderIdHex?.hexToId(), + content = entity.contentJson?.map { it.toDomain() } ?: emptyList(), + timestamp = Instant.fromEpochMilliseconds(entity.timestampEpochMs), + unreadSeq = entity.unreadSeq, + ) + } + + fun toPendingEntity( + chatIdHex: String, + content: List, + senderId: ID, + clientMessageId: ClientMessageId, + ): ChatMessageEntity { + val now = Clock.System.now() + return ChatMessageEntity( + chatIdHex = chatIdHex, + messageId = -(now.toEpochMilliseconds()), + senderIdHex = senderId.hexEncodedString(), + contentJson = content.map { it.toSerialized() }, + timestampEpochMs = now.toEpochMilliseconds(), + unreadSeq = 0, + status = MessageStatus.SENDING, + pendingClientIdHex = clientMessageId.bytes.toList().hexEncodedString(), + ) + } + + // endregion + + // region ChatMember + + fun toEntity(chatIdHex: String, member: ChatMember): ChatMemberEntity { + return ChatMemberEntity( + chatIdHex = chatIdHex, + userIdHex = member.userId.hexEncodedString(), + userProfileJson = member.userProfile.toSerialized(), + pointersJson = member.pointers.map { it.toSerialized() }, + ) + } + + fun toMember(entity: ChatMemberEntity): ChatMember { + return ChatMember( + userId = entity.userIdHex.hexToId(), + userProfile = entity.userProfileJson?.toDomain() ?: UserProfile( + displayName = null, + socialAccounts = emptyList(), + verifiedPhoneNumber = null, + verifiedEmailAddress = null, + ), + pointers = entity.pointersJson?.map { it.toDomain() } ?: emptyList(), + ) + } + + // endregion + + // region Helpers + + fun chatIdHex(chatId: ChatId): String = chatId.bytes.toList().hexEncodedString() + + fun chatIdFromHex(hex: String): ChatId = ChatId(hex.hexToByteArray()) + + fun clientMessageIdHex(clientMessageId: ClientMessageId): String = + clientMessageId.bytes.toList().hexEncodedString() + + fun pointerToJson(pointer: MessagePointer): String { + return kotlinx.serialization.json.Json.encodeToString( + listOf(pointer.toSerialized()) + ) + } + + fun userIdHex(userId: ID): String = userId.hexEncodedString() + + private fun String.hexToByteArray(): ByteArray { + val len = length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() + i += 2 + } + return data + } + + private fun String.hexToId(): List = hexToByteArray().toList() + + // endregion +} + +// region Serialization helpers + +private fun MessageContent.toSerialized(): MessageContentSerialized = when (this) { + is MessageContent.Text -> MessageContentSerialized.Text(text) +} + +private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { + is MessageContentSerialized.Text -> MessageContent.Text(text) +} + +private fun MessagePointer.toSerialized(): MessagePointerSerialized = MessagePointerSerialized( + type = type.name, + userIdHex = userId.hexEncodedString(), + value = value, +) + +private fun MessagePointerSerialized.toDomain(): MessagePointer = MessagePointer( + type = PointerType.entries.firstOrNull { it.name == type } ?: PointerType.UNKNOWN, + userId = userIdHex.hexToIdExt(), + value = value, +) + +private fun UserProfile.toSerialized(): UserProfileSerialized = UserProfileSerialized( + displayName = displayName, + socialAccounts = socialAccounts.map { it.toSerialized() }, + verifiedPhoneNumber = verifiedPhoneNumber, + verifiedEmailAddress = verifiedEmailAddress, +) + +private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( + displayName = displayName, + socialAccounts = socialAccounts.map { it.toDomain() }, + verifiedPhoneNumber = verifiedPhoneNumber, + verifiedEmailAddress = verifiedEmailAddress, +) + +private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { + is SocialAccount.TwitterX -> SocialAccountSerialized.TwitterX( + id = id, + username = username, + name = name, + description = description, + profilePicUrl = profilePicUrl, + verifiedType = verifiedType?.name, + followerCount = followerCount, + ) +} + +private fun SocialAccountSerialized.toDomain(): SocialAccount = when (this) { + is SocialAccountSerialized.TwitterX -> SocialAccount.TwitterX( + id = id, + username = username, + name = name, + description = description, + profilePicUrl = profilePicUrl, + verifiedType = verifiedType?.let { name -> + SocialAccount.TwitterX.VerifiedType.entries.firstOrNull { it.name == name } + }, + followerCount = followerCount, + ) +} + +private fun String.hexToIdExt(): List { + val len = length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() + i += 2 + } + return data.toList() +} + +// endregion diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index f4aca86af..571177520 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(project(":libs:test-utils")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:activityfeed")) implementation(project(":apps:flipcash:shared:analytics")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 17ffcb7df..c8cdb0023 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -8,6 +8,7 @@ import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.PaymentValuation @@ -125,6 +126,7 @@ class RealSessionController @Inject constructor( private val billingClient: BillingClient, private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, + private val chatCoordinator: ChatCoordinator, private val featureFlagController: FeatureFlagController, private val analytics: FlipcashAnalyticsService, private val usdcSweep: UsdcDepositSweep, @@ -156,6 +158,7 @@ class RealSessionController @Inject constructor( stopPolling() cancelUpdates() scope.launch { contactCoordinator.reset() } + scope.launch { chatCoordinator.reset() } _state.update { SessionState() } } authState is AuthState.Ready -> { diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index a2410ab74..6936c57c6 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -113,6 +113,7 @@ class SessionControllerGiftCardErrorTest { analytics = analytics, usdcSweep = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), + chatCoordinator = mockk(relaxed = true), ) } diff --git a/definitions/flipcash/protos/src/main/proto/chat/v1/chat_service.proto b/definitions/flipcash/protos/src/main/proto/chat/v1/chat_service.proto new file mode 100644 index 000000000..7a38e131e --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/chat/v1/chat_service.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +package flipcash.chat.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1;chatpb"; +option java_package = "com.codeinc.flipcash.gen.chat.v1"; +option objc_class_prefix = "FPBChatV1"; + +import "chat/v1/model.proto"; +import "common/v1/common.proto"; +import "validate/validate.proto"; + +service Chat { + // GetChat returns the metadata for a specific chat + rpc GetChat(GetChatRequest) returns (GetChatResponse); + + // GetDmChatFeed gets the set of DM chats for an owner account using + // a paged API, ordered by last activity with the most recent first. + // + // Chats are ordered by a mutable key (last_activity), so pagination alone + // cannot guarantee a complete read: a chat can receive new activity and + // move into a region the client has already paged past. To get the full + // list, the client MUST combine this RPC with the event stream: + // + // 1. Open the event stream to receive ChatUpdate and begin buffering updates + // BEFORE the first GetDmChatFeed call. This ordering is the contract that + // closes the gap; subscribing after pagination starts can drop chats. + // 2. Page through GetDmChatFeed to exhaustion (until has_more is false), + // always echoing back the paging token returned by the prior response. + // All pages are served against a single snapshot pinned by that token, + // so the set is read consistently. + // 3. Merge the buffered and ongoing stream updates onto the paginated + // set. Any chat whose activity changed after the snapshot watermark + // is delivered via the stream rather than via pagination. + // + // Read together, pagination guarantees the set (every chat exactly once) + // and the stream guarantees freshness and ordering. The local last_activity + // sort is maintained by the client from the stream after the initial read. + rpc GetDmChatFeed(GetDmChatFeedRequest) returns (GetDmChatFeedResponse); +} + +message GetChatRequest { + common.v1.ChatId chat_id = 1; + + common.v1.Auth auth = 10; +} + +message GetChatResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + } + + Metadata metadata = 2; +} + +message GetDmChatFeedRequest { + // QueryOptions controls page_size. Ordering is fixed to most-recent + // activity first and is not client-selectable. + // + // Leave query_options.paging_token unset on the first request: the server + // mints a token that pins a new snapshot and returns it in the response. On + // every subsequent request, set query_options.paging_token to the + // paging_token from the most recent response to advance within the same + // snapshot. The token is opaque and server-generated; do not construct it. + common.v1.QueryOptions query_options = 1; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetDmChatFeedResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + } + + repeated Metadata chats = 2 [(validate.rules).repeated = { + min_items: 0 + max_items: 100 + }]; + + // PagingToken is the server-generated token for this paginated read. On the + // first response it pins a new snapshot; on later responses it carries the + // advanced cursor over (last_activity, chat_id). The client MUST send the + // most recent value back in query_options.paging_token on the next + // GetDmChatFeedRequest. Set when result is OK. + common.v1.PagingToken paging_token = 3; + + // HasMore indicates whether further pages remain in this snapshot. When + // false, the paginated set has been fully read; the complete chat list is + // this set reconciled with the event stream (see GetDmChatFeed). When true, the + // client should issue another GetDmChatFeedRequest with the returned + // paging_token. + bool has_more = 4; +} diff --git a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto new file mode 100644 index 000000000..7256ca6d2 --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package flipcash.chat.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1;chatpb"; +option java_package = "com.codeinc.flipcash.gen.chat.v1"; +option objc_class_prefix = "FPBChatV1"; + +import "common/v1/common.proto"; +import "profile/v1/model.proto"; +import "messaging/v1/model.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +message Metadata { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + // The type of chat + ChatType type = 2 [(validate.rules).enum = { + not_in: [0] // UNKNOWN + }]; + enum ChatType { + UNKNOWN = 0; + DM = 1; + } + + // Members of this chat + repeated Member members = 3; + + // The last message in this chat + messaging.v1.Message last_message = 4; + + // The timestamp of the last activity in this chat + google.protobuf.Timestamp last_activity = 5 [(validate.rules).timestamp.required = true]; +} + +message Member { + common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + + // The user profile for this member. It contains a subset of identifiers + // that can be publicly viewed within the chat. + profile.v1.UserProfile user_profile = 2 [(validate.rules).message.required = true]; + + // Chat message state for this member. + // + // If set, the list may contain DELIVERED and READ pointers. SENT pointers + // are only shared between the sender and server, to indicate persistence. + repeated messaging.v1.Pointer pointers = 3 [(validate.rules).repeated = { + min_items: 0 + max_items: 2 + }]; +} + +message MetadataUpdate { + oneof kind { + option (validate.required) = true; + + FullRefresh full_refresh = 1; + LastActivityChanged last_activity_changed = 2; + } + + // Refreshes the entire chat metadata + message FullRefresh { + Metadata metadata = 1 [(validate.rules).message.required = true]; + } + + // The last activity timestamp has changed to a newer value + message LastActivityChanged { + google.protobuf.Timestamp new_last_activity = 1 [(validate.rules).timestamp.required = true]; + } +} diff --git a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto index d43c840dd..6d42623c7 100644 --- a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto +++ b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto @@ -58,6 +58,13 @@ message UserId { }]; } +message ChatId { + bytes value = 1 [(validate.rules).bytes = { + min_len: 32 + max_len: 32 + }]; +} + // AppInstallId is a unque ID tied to a client app installation. It does not // identify a device. Value should remain private and not be shared across // installs. diff --git a/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto b/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto index 9276a6c82..c78e4e273 100644 --- a/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto +++ b/definitions/flipcash/protos/src/main/proto/contact/v1/contact_list_service.proto @@ -9,6 +9,7 @@ import "validate/validate.proto"; option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb"; option java_package = "com.codeinc.flipcash.gen.contact.v1"; +option objc_class_prefix = "FPBContactV1"; // ContactList manages a user's contact list and surfaces which contacts are // Flipcash users. diff --git a/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto index c6f79bbd5..c6a62e8c0 100644 --- a/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto @@ -7,6 +7,7 @@ import "validate/validate.proto"; option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb"; option java_package = "com.codeinc.flipcash.gen.contact.v1"; +option objc_class_prefix = "FPBContactV1"; message FlipcashContact { phone.v1.PhoneNumber phone = 1 [(validate.rules).message.required = true]; diff --git a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto index 3b1bfaa10..6262a4995 100644 --- a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto @@ -6,7 +6,9 @@ option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/g option java_package = "com.codeinc.flipcash.gen.events.v1"; option objc_class_prefix = "FPBEventV1"; +import "chat/v1/model.proto"; import "common/v1/common.proto"; +import "messaging/v1/model.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; @@ -27,7 +29,8 @@ message Event { oneof type { option (validate.required) = true; - TestEvent test = 3; + TestEvent test = 3; + ChatUpdate chat_update = 4; } } @@ -71,3 +74,22 @@ message ClientPong { // of potential network latency google.protobuf.Timestamp timestamp = 1 [(validate.rules).timestamp.required = true]; } + +message ChatUpdate { + // The chat that this update is for + common.v1.ChatId chat = 1 [(validate.rules).message.required = true]; + + // If present, new real-time messages sent on the chat + messaging.v1.MessageBatch new_messages = 2; + + // If present, message pointer updates for members in the chat + messaging.v1.PointerBatch pointer_updates = 3; + + // If present, message typing notification state changes for members in the chat + messaging.v1.IsTypingNotificationBatch is_typing_notifications = 4; + + // If present, updates to the chat metadata + repeated chat.v1.MetadataUpdate metadata_updates = 5 [(validate.rules).repeated = { + max_items: 1024 // Arbitrary + }]; +} diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto new file mode 100644 index 000000000..975451c6a --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto @@ -0,0 +1,140 @@ +syntax = "proto3"; + +package flipcash.messaging.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1;messagingpb"; +option java_package = "com.codeinc.flipcash.gen.messaging.v1"; +option objc_class_prefix = "FPBMessagingV1"; + +import "common/v1/common.proto"; +import "messaging/v1/model.proto"; +import "validate/validate.proto"; + +service Messaging { + // GetMessage gets a single message in a chat + rpc GetMessage(GetMessageRequest) returns (GetMessageResponse); + + // GetMessages gets the set of messages for a chat using a paged and batched APIs + rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + + // SendMessage sends a message to a chat. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); + + // AdvancePointer advances a pointer in message history for a chat member. + rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); + + // NotifyIsTypingRequest notifies a chat that the sending member is typing. + // + // These requests are transient, and may be dropped at any point. + rpc NotifyIsTyping(NotifyIsTypingRequest) returns (NotifyIsTypingResponse); +} + +message GetMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10; +} + +message GetMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + } + + Message message = 2; +} + +message GetMessagesRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + oneof query { + option (validate.required) = true; + + common.v1.QueryOptions options = 2; + MessageIdBatch message_ids = 3; + } + + common.v1.Auth auth = 10; +} + +message GetMessagesResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + NOT_FOUND = 2; + } + + MessageBatch messages = 2; +} + +message SendMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + // Allowed content types that can be sent by client: + // - TextContent + repeated Content content = 2 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Client-generated idempotency token for this send. Used to dedup retried + // sends and to correlate the optimistic local echo with the server-assigned + // message returned in the response. + ClientMessageId client_message_id = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message SendMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + + // The chat message that was sent if the RPC was succesful, which includes + // server-side metadata like the generated message ID and official timestamp + Message message = 2; +} + +message AdvancePointerRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + Pointer.Type pointer_type = 2 [(validate.rules).enum = { + in: [2, 3] // DELIVERED, READ + }]; + + MessageId new_value = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message AdvancePointerResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } +} + +message NotifyIsTypingRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + IsTypingNotification.State state = 2; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message NotifyIsTypingResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } +} \ No newline at end of file diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto new file mode 100644 index 000000000..10c7cf34f --- /dev/null +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto @@ -0,0 +1,147 @@ +syntax = "proto3"; + +package flipcash.messaging.v1; + +option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1;messagingpb"; +option java_package = "com.codeinc.flipcash.gen.messaging.v1"; +option objc_class_prefix = "FPBMessagingV1"; + +import "common/v1/common.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +message MessageId { + // Per-chat, server-assigned, gapless sequence number. Together with the + // chat ID this is the message's canonical identity, sort key, and + // pagination cursor. Gapless ordering lets clients trivially detect missing + // messages: a complete history has no gaps between consecutive numbers. + uint64 value = 1 [(validate.rules).uint64.gte = 1]; +} + +// ClientMessageId is a client-generated identifier for a message send. +// +// It serves two purposes: +// - Idempotency: the server dedups on this value, so a retried SendMessage +// (e.g. after a network failure) returns the originally created message +// instead of assigning a new sequence number and creating a duplicate. +// - Correlation: clients use it to match an optimistic local echo to the +// server-assigned Message returned in the response. +// +// Unlike MessageId, this is owned by the client and is not the message's +// canonical identity; it is typically a randomly generated UUID. +message ClientMessageId { + bytes value = 1 [(validate.rules).bytes = { + min_len: 16 + max_len: 16 + }]; +} + +// A message in a chat +message Message { + // Per-chat sequence number identifying this message + MessageId message_id = 1 [(validate.rules).message.required = true]; + + // The chat member that sent the message. For system-level messages, + // this will be ommitted. + common.v1.UserId sender_id = 2; + + // Message content, which is currently guaranteed to have exactly one item. + repeated Content content = 3 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Timestamp this message was generated at. + google.protobuf.Timestamp ts = 4 [(validate.rules).timestamp.required = true]; + + // The number of unread-eligible messages in this chat up to and including + // this message. This is a SEPARATE sequence from message_id: messages that + // don't count toward unread keep their message_id but do NOT advance this + // value — they carry the previous count forward, so every message reports + // the running total. A member's unread count is computed entirely on the + // client as the difference between the latest message's unread_seq and the + // unread_seq of the message at their READ pointer. + uint64 unread_seq = 5; +} + +// Content for a chat message +message Content { + oneof type { + option (validate.required) = true; + + TextContent text = 1; + } +} + +// Raw text content +message TextContent { + string text = 1 [(validate.rules).string = { + min_len: 1 + max_len: 4096 + }]; +} + +// Pointer in a chat indicating a user's message history state in a chat. +message Pointer { + // The type of pointer indicates which user's message history state can be + // inferred from the pointer value. It is also possible to infer cross-pointer + // state. For example, if a chat member has a READ pointer for a message with + // ID N, then the DELIVERED pointer must be at least N. + Type type = 1 [(validate.rules).enum = { + not_in: [0] + }]; + enum Type { + UNKNOWN = 0; + SENT = 1; // Always inferred by OK result in SendMessageResponse or message presence in a chat + DELIVERED = 2; + READ = 3; + } + + // The user ID associated with the pointer + common.v1.UserId user_id = 2 [(validate.rules).message.required = true]; + + // Everything at or before this message ID is considered to have the state + // inferred by the type of pointer. + MessageId value = 3 [(validate.rules).message.required = true]; +} + +message MessageIdBatch { + repeated MessageId message_ids = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +message MessageBatch { + repeated Message messages = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +message PointerBatch { + repeated Pointer pointers = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +message IsTypingNotification { + common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + + State state = 2; + enum State { + UNKNOWN_TYPING_STATE = 0; + STARTED_TYPING = 1; + STILL_TYPING = 2; + STOPPED_TYPING = 3; + TYPING_TIMED_OUT = 4; + } +} + +message IsTypingNotificationBatch { + repeated IsTypingNotification is_typing_notifications = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 // Arbitrary + }]; +} diff --git a/libs/models/src/main/kotlin/com/getcode/model/ID.kt b/libs/models/src/main/kotlin/com/getcode/model/ID.kt index 92b9a1623..975399283 100644 --- a/libs/models/src/main/kotlin/com/getcode/model/ID.kt +++ b/libs/models/src/main/kotlin/com/getcode/model/ID.kt @@ -9,7 +9,7 @@ typealias ID = List val NoId: ID = emptyList() -val RandomId: ID = UUID.randomUUID().bytes +val RandomId: ID get() = UUID.randomUUID().bytes val ID.uuid: UUID? get() { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt new file mode 100644 index 000000000..dfbc1126f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -0,0 +1,30 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.repository.ChatRepository +import com.flipcash.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatController @Inject constructor( + private val repository: ChatRepository, + private val userManager: UserManager, +) { + suspend fun getChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getChat(owner, chatId) + } + + suspend fun getDmChatFeed(queryOptions: QueryOptions = QueryOptions()): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getDmChatFeed(owner, queryOptions) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt new file mode 100644 index 000000000..87f78aefb --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt @@ -0,0 +1,63 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatMessagingController @Inject constructor( + private val repository: ChatMessagingRepository, + private val userManager: UserManager, +) { + private fun requireOwner() = userManager.accountCluster?.authority?.keyPair + ?: throw IllegalStateException("No account cluster in UserManager") + + suspend fun getMessage(chatId: ChatId, messageId: Long): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getMessage(owner, chatId, messageId) + } + + suspend fun getMessages(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result> { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getMessages(owner, chatId, queryOptions) + } + + suspend fun getMessagesByIds(chatId: ChatId, messageIds: List): Result> { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getMessagesByIds(owner, chatId, messageIds) + } + + suspend fun sendMessage( + chatId: ChatId, + content: List, + clientMessageId: ClientMessageId, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.sendMessage(owner, chatId, content, clientMessageId) + } + + suspend fun advancePointer( + chatId: ChatId, + pointerType: PointerType, + messageId: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.advancePointer(owner, chatId, pointerType, messageId) + } + + suspend fun notifyIsTyping( + chatId: ChatId, + state: TypingState, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.notifyIsTyping(owner, chatId, state) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt new file mode 100644 index 000000000..6cc5d9eee --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/EventStreamingController.kt @@ -0,0 +1,53 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.internal.network.services.EventStreamReference +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.repository.EventStreamingRepository +import com.flipcash.services.user.UserManager +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EventStreamingController @Inject constructor( + private val repository: EventStreamingRepository, + private val userManager: UserManager, +) { + private val _chatUpdates = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, + ) + val chatUpdates: SharedFlow = _chatUpdates.asSharedFlow() + + private var streamRef: EventStreamReference? = null + + fun open(scope: CoroutineScope) { + val owner = userManager.accountCluster?.authority?.keyPair ?: run { + trace("EventStreamingController: No account cluster, cannot open stream") + return + } + + close() + + streamRef = repository.openEventStream( + scope = scope, + owner = owner, + onEvent = { update -> + _chatUpdates.tryEmit(update) + }, + onError = { error -> + trace("EventStreamingController: Stream error: ${error.message}") + }, + ) + } + + fun close() { + streamRef?.destroy() + streamRef = null + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index c076fdfa0..26cf1332e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -9,8 +9,12 @@ import com.flipcash.services.internal.domain.UserFlagsMapper import com.flipcash.services.internal.domain.SocialAccountMapper import com.flipcash.services.internal.domain.TextModerationResponseMapper import com.flipcash.services.internal.domain.UserProfileMapper +import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.network.services.AccountService import com.flipcash.services.internal.network.services.ActivityFeedService +import com.flipcash.services.internal.network.services.ChatService +import com.flipcash.services.internal.network.services.EventStreamingService +import com.flipcash.services.internal.network.services.ChatMessagingService import com.flipcash.services.internal.network.services.EmailVerificationService import com.flipcash.services.internal.network.services.ContactListService import com.flipcash.services.internal.network.services.ModerationService @@ -23,6 +27,9 @@ import com.flipcash.services.internal.network.services.SettingsService import com.flipcash.services.internal.network.services.ThirdPartyService import com.flipcash.services.internal.repositories.InternalAccountRepository import com.flipcash.services.internal.repositories.InternalActivityFeedRepository +import com.flipcash.services.internal.repositories.InternalChatRepository +import com.flipcash.services.internal.repositories.InternalEventStreamingRepository +import com.flipcash.services.internal.repositories.InternalChatMessagingRepository import com.flipcash.services.internal.repositories.InternalContactListRepository import com.flipcash.services.internal.repositories.InternalContactVerificationRepository import com.flipcash.services.internal.repositories.InternalModerationRepository @@ -34,6 +41,9 @@ import com.flipcash.services.internal.repositories.InternalSettingsRepository import com.flipcash.services.internal.repositories.InternalThirdPartyRepository import com.flipcash.services.repository.AccountRepository import com.flipcash.services.repository.ActivityFeedRepository +import com.flipcash.services.repository.ChatRepository +import com.flipcash.services.repository.EventStreamingRepository +import com.flipcash.services.repository.ChatMessagingRepository import com.flipcash.services.repository.ContactListRepository import com.flipcash.services.repository.ContactVerificationRepository import com.flipcash.services.repository.ModerationRepository @@ -114,6 +124,22 @@ internal object FlipcashModule { } } + @Provides + internal fun providesChatRepository( + service: ChatService, + mapper: ChatMetadataMapper, + ): ChatRepository = InternalChatRepository(service, mapper) + + @Provides + internal fun providesEventStreamingRepository( + service: EventStreamingService, + ): EventStreamingRepository = InternalEventStreamingRepository(service) + + @Provides + internal fun providesChatMessagingRepository( + service: ChatMessagingService, + ): ChatMessagingRepository = InternalChatMessagingRepository(service) + @Provides internal fun providesContactListRepository( service: ContactListService, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt new file mode 100644 index 000000000..b4b6bd7da --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -0,0 +1,33 @@ +package com.flipcash.services.internal.domain + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.flipcash.services.internal.domain.mapper.Mapper +import com.flipcash.services.internal.network.extensions.toChatId +import com.flipcash.services.internal.network.extensions.toChatMessage +import com.flipcash.services.internal.network.extensions.toChatType +import com.flipcash.services.internal.network.extensions.toId +import com.flipcash.services.internal.network.extensions.toPointer +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import kotlin.time.Instant +import javax.inject.Inject + +class ChatMetadataMapper @Inject constructor( + private val userProfileMapper: UserProfileMapper, +) : Mapper { + override fun map(from: ChatModel.Metadata): ChatMetadata { + return ChatMetadata( + chatId = from.chatId.toChatId(), + type = from.type.toChatType(), + members = from.membersList.map { member -> + ChatMember( + userId = member.userId.toId(), + userProfile = userProfileMapper.map(member.userProfile), + pointers = member.pointersList.map { it.toPointer() }, + ) + }, + lastMessage = if (from.hasLastMessage()) from.lastMessage.toChatMessage() else null, + lastActivity = Instant.fromEpochSeconds(from.lastActivity.seconds, from.lastActivity.nanos), + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt new file mode 100644 index 000000000..29300c2ee --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -0,0 +1,61 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.chat.v1.ChatGrpcKt +import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService +import com.codeinc.flipcash.gen.chat.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asChatId +import com.flipcash.services.internal.network.extensions.asQueryOptions +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ChatApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = ChatGrpcKt.ChatCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun getChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.GetChatResponse { + val request = RpcChatService.GetChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getChat(request) + } + } + + suspend fun getDmChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): RpcChatService.GetDmChatFeedResponse { + val request = RpcChatService.GetDmChatFeedRequest.newBuilder() + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getDmChatFeed(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt new file mode 100644 index 000000000..3b7f37c0f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -0,0 +1,153 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.messaging.v1.MessagingGrpcKt +import com.codeinc.flipcash.gen.messaging.v1.MessagingService as RpcMessagingService +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.codeinc.flipcash.gen.messaging.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asChatId +import com.flipcash.services.internal.network.extensions.asClientMessageId +import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.asPointerType +import com.flipcash.services.internal.network.extensions.asQueryOptions +import com.flipcash.services.internal.network.extensions.asTypingState +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ChatMessagingApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = MessagingGrpcKt.MessagingCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun getMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcMessagingService.GetMessageResponse { + val request = RpcMessagingService.GetMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId)) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getMessage(request) + } + } + + suspend fun getMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): RpcMessagingService.GetMessagesResponse { + val request = RpcMessagingService.GetMessagesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getMessages(request) + } + } + + suspend fun getMessagesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): RpcMessagingService.GetMessagesResponse { + val request = RpcMessagingService.GetMessagesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageIds( + MessagingModel.MessageIdBatch.newBuilder() + .addAllMessageIds(messageIds.map { MessagingModel.MessageId.newBuilder().setValue(it).build() }) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getMessages(request) + } + } + + suspend fun sendMessage( + owner: KeyPair, + chatId: ChatId, + content: List, + clientMessageId: ClientMessageId, + ): RpcMessagingService.SendMessageResponse { + val request = RpcMessagingService.SendMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .addAllContent(content.map { it.asContent() }) + .setClientMessageId(clientMessageId.asClientMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.sendMessage(request) + } + } + + suspend fun advancePointer( + owner: KeyPair, + chatId: ChatId, + pointerType: PointerType, + messageId: Long, + ): RpcMessagingService.AdvancePointerResponse { + val request = RpcMessagingService.AdvancePointerRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setPointerType(pointerType.asPointerType()) + .setNewValue(MessagingModel.MessageId.newBuilder().setValue(messageId)) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.advancePointer(request) + } + } + + suspend fun notifyIsTyping( + owner: KeyPair, + chatId: ChatId, + state: TypingState, + ): RpcMessagingService.NotifyIsTypingResponse { + val request = RpcMessagingService.NotifyIsTypingRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setState(state.asTypingState()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.notifyIsTyping(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EventStreamingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EventStreamingApi.kt new file mode 100644 index 000000000..0b98db63d --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EventStreamingApi.kt @@ -0,0 +1,26 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.events.v1.EventStreamingGrpcKt +import com.codeinc.flipcash.gen.events.v1.EventStreamingService +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.getcode.opencode.internal.network.core.GrpcApi +import io.grpc.ManagedChannel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class EventStreamingApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = EventStreamingGrpcKt.EventStreamingCoroutineStub(managedChannel) + .withWaitForReady() + + fun streamEvents( + requests: Flow, + ): Flow { + return api.streamEvents(requests) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index a98f79381..e6af30b9f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -7,6 +7,11 @@ import com.codeinc.flipcash.gen.thirdparty.v1.Model as ThirdPartyModels import com.flipcash.services.models.PagingToken import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.SocialAccountLinkRequest +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.network.jwt.ApiProvider import com.getcode.opencode.model.core.ID @@ -14,7 +19,8 @@ import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.PublicKey import com.getcode.utils.toByteString import com.google.protobuf.Timestamp -import kotlinx.datetime.Instant +import kotlin.time.Instant +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel internal fun Checksum.asHash(): Common.Hash { return Common.Hash.newBuilder().setValue(byteArray.toByteString()).build() @@ -73,6 +79,10 @@ internal fun Pair.asApiKey(): ThirdPartyModels.ApiKey { .build() } +internal fun ChatId.asChatId(): Common.ChatId { + return Common.ChatId.newBuilder().setValue(bytes.toByteString()).build() +} + internal fun String.asCountryCode(): Common.CountryCode { return Common.CountryCode.newBuilder().setValue(this).build() } @@ -87,4 +97,37 @@ internal fun SocialAccountLinkRequest.linkingToken(): ProfileService.LinkSocialA } return builder.build() +} + +// -- Messaging extensions -- + +internal fun ClientMessageId.asClientMessageId(): MessagingModel.ClientMessageId { + return MessagingModel.ClientMessageId.newBuilder().setValue(bytes.toByteString()).build() +} + +internal fun MessageContent.asContent(): MessagingModel.Content { + return when (this) { + is MessageContent.Text -> MessagingModel.Content.newBuilder() + .setText(MessagingModel.TextContent.newBuilder().setText(text)) + .build() + } +} + +internal fun PointerType.asPointerType(): MessagingModel.Pointer.Type { + return when (this) { + PointerType.SENT -> MessagingModel.Pointer.Type.SENT + PointerType.DELIVERED -> MessagingModel.Pointer.Type.DELIVERED + PointerType.READ -> MessagingModel.Pointer.Type.READ + PointerType.UNKNOWN -> MessagingModel.Pointer.Type.UNKNOWN + } +} + +internal fun TypingState.asTypingState(): MessagingModel.IsTypingNotification.State { + return when (this) { + TypingState.STARTED_TYPING -> MessagingModel.IsTypingNotification.State.STARTED_TYPING + TypingState.STILL_TYPING -> MessagingModel.IsTypingNotification.State.STILL_TYPING + TypingState.STOPPED_TYPING -> MessagingModel.IsTypingNotification.State.STOPPED_TYPING + TypingState.TYPING_TIMED_OUT -> MessagingModel.IsTypingNotification.State.TYPING_TIMED_OUT + TypingState.UNKNOWN -> MessagingModel.IsTypingNotification.State.UNKNOWN_TYPING_STATE + } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index eb66cffc5..d4be371a1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -12,13 +12,28 @@ import com.flipcash.services.internal.extensions.toPublicKey import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.services.models.PagingToken import com.flipcash.services.models.Substitution +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingNotification +import com.flipcash.services.models.chat.TypingState import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.solana.keys.Signature +import kotlin.time.Instant import com.codeinc.flipcash.gen.activity.v1.Model as ActivityModels +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.events.v1.Model as EventModel +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel internal fun ActivityModels.NotificationId.toId(): ID = value.toByteArray().toList() internal fun Common.UserId.toId(): ID = value.toByteArray().toList() @@ -71,4 +86,130 @@ internal fun PushModels.Substitution.asSubstitution(): Substitution? { internal fun Common.Signature.toSignature(): Signature { return Signature(value.toByteArray().toList()) +} + +// -- ChatId -- + +internal fun Common.ChatId.toChatId(): ChatId = ChatId(value.toByteArray()) + +// -- PagingToken (proto → domain) -- + +internal fun Common.PagingToken.toPagingToken(): PagingToken = value.toByteArray().toList() + +// -- Messaging models -- + +internal fun MessagingModel.Content.toMessageContent(): MessageContent { + return when (typeCase) { + MessagingModel.Content.TypeCase.TEXT -> MessageContent.Text(text.text) + else -> MessageContent.Text("") + } +} + +internal fun MessagingModel.Message.toChatMessage(): ChatMessage { + return ChatMessage( + messageId = messageId.value, + senderId = if (hasSenderId()) senderId.toId() else null, + content = contentList.map { it.toMessageContent() }, + timestamp = Instant.fromEpochSeconds(ts.seconds, ts.nanos), + unreadSeq = unreadSeq, + ) +} + +internal fun MessagingModel.Pointer.toPointer(): MessagePointer { + return MessagePointer( + type = type.toPointerType(), + userId = userId.toId(), + value = value.value, + ) +} + +internal fun MessagingModel.Pointer.Type.toPointerType(): PointerType { + return when (this) { + MessagingModel.Pointer.Type.SENT -> PointerType.SENT + MessagingModel.Pointer.Type.DELIVERED -> PointerType.DELIVERED + MessagingModel.Pointer.Type.READ -> PointerType.READ + else -> PointerType.UNKNOWN + } +} + +internal fun MessagingModel.IsTypingNotification.toTypingNotification(): TypingNotification { + return TypingNotification( + userId = userId.toId(), + state = state.toTypingState(), + ) +} + +internal fun MessagingModel.IsTypingNotification.State.toTypingState(): TypingState { + return when (this) { + MessagingModel.IsTypingNotification.State.STARTED_TYPING -> TypingState.STARTED_TYPING + MessagingModel.IsTypingNotification.State.STILL_TYPING -> TypingState.STILL_TYPING + MessagingModel.IsTypingNotification.State.STOPPED_TYPING -> TypingState.STOPPED_TYPING + MessagingModel.IsTypingNotification.State.TYPING_TIMED_OUT -> TypingState.TYPING_TIMED_OUT + else -> TypingState.UNKNOWN + } +} + +// -- Chat metadata updates -- + +internal fun ChatModel.MetadataUpdate.toMetadataUpdate( + metadataMapper: (ChatModel.Metadata) -> com.flipcash.services.models.chat.ChatMetadata, +): MetadataUpdate { + return when (kindCase) { + ChatModel.MetadataUpdate.KindCase.FULL_REFRESH -> + MetadataUpdate.FullRefresh(metadataMapper(fullRefresh.metadata)) + ChatModel.MetadataUpdate.KindCase.LAST_ACTIVITY_CHANGED -> + MetadataUpdate.LastActivityChanged( + Instant.fromEpochSeconds( + lastActivityChanged.newLastActivity.seconds, + lastActivityChanged.newLastActivity.nanos, + ) + ) + else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) + } +} + +// -- Chat type -- + +internal fun ChatModel.Metadata.ChatType.toChatType(): ChatType { + return when (this) { + ChatModel.Metadata.ChatType.DM -> ChatType.DM + else -> ChatType.UNKNOWN + } +} + +// -- Chat metadata (simple, no injected mapper) -- + +internal fun ChatModel.Metadata.toChatMetadata(): com.flipcash.services.models.chat.ChatMetadata { + return com.flipcash.services.models.chat.ChatMetadata( + chatId = chatId.toChatId(), + type = type.toChatType(), + members = membersList.map { member -> + com.flipcash.services.models.chat.ChatMember( + userId = member.userId.toId(), + userProfile = com.flipcash.services.models.UserProfile( + displayName = member.userProfile.displayName, + socialAccounts = emptyList(), + verifiedPhoneNumber = null, + verifiedEmailAddress = null, + ), + pointers = member.pointersList.map { it.toPointer() }, + ) + }, + lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, + lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), + ) +} + +// -- EventModel.ChatUpdate -- + +internal fun EventModel.ChatUpdate.toChatUpdate( + metadataMapper: (ChatModel.Metadata) -> com.flipcash.services.models.chat.ChatMetadata = { it.toChatMetadata() }, +): ChatUpdate { + return ChatUpdate( + chatId = chat.toChatId(), + newMessages = if (hasNewMessages()) newMessages.messagesList.map { it.toChatMessage() } else emptyList(), + pointerUpdates = if (hasPointerUpdates()) pointerUpdates.pointersList.map { it.toPointer() } else emptyList(), + typingNotifications = if (hasIsTypingNotifications()) isTypingNotifications.isTypingNotificationsList.map { it.toTypingNotification() } else emptyList(), + metadataUpdates = metadataUpdatesList.map { it.toMetadataUpdate(metadataMapper) }, + ) } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt new file mode 100644 index 000000000..de0c4b60a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -0,0 +1,164 @@ +package com.flipcash.services.internal.network.services + +import com.codeinc.flipcash.gen.messaging.v1.MessagingService as RpcMessagingService +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.internal.network.api.ChatMessagingApi +import com.flipcash.services.models.AdvancePointerError +import com.flipcash.services.models.SendMessageError +import com.flipcash.services.models.GetMessageError +import com.flipcash.services.models.GetMessagesError +import com.flipcash.services.models.NotifyIsTypingError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import javax.inject.Inject + +internal class ChatMessagingService @Inject constructor( + private val api: ChatMessagingApi, +) { + suspend fun getMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.getMessage(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.GetMessageResponse.Result.DENIED -> Result.failure(GetMessageError.Denied()) + RpcMessagingService.GetMessageResponse.Result.NOT_FOUND -> Result.failure(GetMessageError.NotFound()) + RpcMessagingService.GetMessageResponse.Result.UNRECOGNIZED -> Result.failure(GetMessageError.Unrecognized()) + else -> Result.failure(GetMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetMessageError.Other(cause = it) }) + } + ) + } + + suspend fun getMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> { + return runCatching { + api.getMessages(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetMessagesResponse.Result.OK -> + Result.success(if (response.hasMessages()) response.messages.messagesList else emptyList()) + RpcMessagingService.GetMessagesResponse.Result.DENIED -> Result.failure(GetMessagesError.Denied()) + RpcMessagingService.GetMessagesResponse.Result.NOT_FOUND -> Result.failure(GetMessagesError.NotFound()) + RpcMessagingService.GetMessagesResponse.Result.UNRECOGNIZED -> Result.failure(GetMessagesError.Unrecognized()) + else -> Result.failure(GetMessagesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetMessagesError.Other(cause = it) }) + } + ) + } + + suspend fun getMessagesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> { + return runCatching { + api.getMessagesByIds(owner, chatId, messageIds) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetMessagesResponse.Result.OK -> + Result.success(if (response.hasMessages()) response.messages.messagesList else emptyList()) + RpcMessagingService.GetMessagesResponse.Result.DENIED -> Result.failure(GetMessagesError.Denied()) + RpcMessagingService.GetMessagesResponse.Result.NOT_FOUND -> Result.failure(GetMessagesError.NotFound()) + RpcMessagingService.GetMessagesResponse.Result.UNRECOGNIZED -> Result.failure(GetMessagesError.Unrecognized()) + else -> Result.failure(GetMessagesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetMessagesError.Other(cause = it) }) + } + ) + } + + suspend fun sendMessage( + owner: KeyPair, + chatId: ChatId, + content: List, + clientMessageId: ClientMessageId, + ): Result { + return runCatching { + api.sendMessage(owner, chatId, content, clientMessageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.SendMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.SendMessageResponse.Result.DENIED -> Result.failure(SendMessageError.Denied()) + RpcMessagingService.SendMessageResponse.Result.UNRECOGNIZED -> Result.failure(SendMessageError.Unrecognized()) + else -> Result.failure(SendMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SendMessageError.Other(cause = it) }) + } + ) + } + + suspend fun advancePointer( + owner: KeyPair, + chatId: ChatId, + pointerType: PointerType, + messageId: Long, + ): Result { + return runCatching { + api.advancePointer(owner, chatId, pointerType, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.AdvancePointerResponse.Result.OK -> Result.success(Unit) + RpcMessagingService.AdvancePointerResponse.Result.DENIED -> Result.failure(AdvancePointerError.Denied()) + RpcMessagingService.AdvancePointerResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(AdvancePointerError.MessageNotFound()) + RpcMessagingService.AdvancePointerResponse.Result.UNRECOGNIZED -> Result.failure(AdvancePointerError.Unrecognized()) + else -> Result.failure(AdvancePointerError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { AdvancePointerError.Other(cause = it) }) + } + ) + } + + suspend fun notifyIsTyping( + owner: KeyPair, + chatId: ChatId, + state: TypingState, + ): Result { + return runCatching { + api.notifyIsTyping(owner, chatId, state) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.NotifyIsTypingResponse.Result.OK -> Result.success(Unit) + RpcMessagingService.NotifyIsTypingResponse.Result.DENIED -> Result.failure(NotifyIsTypingError.Denied()) + RpcMessagingService.NotifyIsTypingResponse.Result.UNRECOGNIZED -> Result.failure(NotifyIsTypingError.Unrecognized()) + else -> Result.failure(NotifyIsTypingError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { NotifyIsTypingError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt new file mode 100644 index 000000000..743d61b7b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -0,0 +1,61 @@ +package com.flipcash.services.internal.network.services + +import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.flipcash.services.internal.network.api.ChatApi +import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.GetDmChatFeedError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import javax.inject.Inject + +internal class ChatService @Inject constructor( + private val api: ChatApi, +) { + suspend fun getChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.getChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetChatResponse.Result.OK -> Result.success(response.metadata) + RpcChatService.GetChatResponse.Result.DENIED -> Result.failure(GetChatError.Denied()) + RpcChatService.GetChatResponse.Result.NOT_FOUND -> Result.failure(GetChatError.NotFound()) + RpcChatService.GetChatResponse.Result.UNRECOGNIZED -> Result.failure(GetChatError.Unrecognized()) + else -> Result.failure(GetChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetChatError.Other(cause = it) }) + } + ) + } + + suspend fun getDmChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getDmChatFeed(owner, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetDmChatFeedResponse.Result.OK -> Result.success(response) + RpcChatService.GetDmChatFeedResponse.Result.DENIED -> Result.failure(GetDmChatFeedError.Denied()) + RpcChatService.GetDmChatFeedResponse.Result.NOT_FOUND -> Result.failure(GetDmChatFeedError.NotFound()) + RpcChatService.GetDmChatFeedResponse.Result.UNRECOGNIZED -> Result.failure(GetDmChatFeedError.Unrecognized()) + else -> Result.failure(GetDmChatFeedError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetDmChatFeedError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt new file mode 100644 index 000000000..2de483795 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt @@ -0,0 +1,137 @@ +package com.flipcash.services.internal.network.services + +import com.codeinc.flipcash.gen.events.v1.EventStreamingService as RpcEventStreamingService +import com.codeinc.flipcash.gen.events.v1.Model as EventModel +import com.flipcash.services.internal.network.api.EventStreamingApi +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.toChatUpdate +import com.flipcash.services.models.StreamEventsError +import com.flipcash.services.models.chat.ChatUpdate +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.bidi.BidirectionalStreamReference +import com.getcode.opencode.internal.bidi.openBidirectionalStream +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import com.google.protobuf.Timestamp +import kotlin.time.Clock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +typealias EventStreamReference = BidirectionalStreamReference + +internal class EventStreamingService @Inject constructor( + private val api: EventStreamingApi, +) { + fun openEventStream( + scope: CoroutineScope, + owner: KeyPair, + onEvent: (ChatUpdate) -> Unit, + onError: (Throwable) -> Unit = {}, + ): EventStreamReference { + trace("EventStream Opening stream.") + val streamReference = EventStreamReference(scope, "event-streaming") + + streamReference.retain() + streamReference.timeoutHandler = { + trace("EventStream timed out") + streamReference.coroutineScope.launch { + openStream(scope, owner, streamReference, onEvent, onError) + } + } + + streamReference.coroutineScope.launch { + openStream(scope, owner, streamReference, onEvent, onError) + } + + return streamReference + } + + private fun openStream( + scope: CoroutineScope, + owner: KeyPair, + streamRef: EventStreamReference, + onEvent: (ChatUpdate) -> Unit, + onError: (Throwable) -> Unit, + ) { + openBidirectionalStream( + streamRef = streamRef, + apiCall = api::streamEvents, + initialRequest = { + val params = RpcEventStreamingService.StreamEventsRequest.Params.newBuilder() + .setTs(currentTimestamp()) + .apply { setAuth(authenticate(owner)) } + .build() + + RpcEventStreamingService.StreamEventsRequest.newBuilder() + .setParams(params) + .build() + }, + reconnectOnUnavailable = true, + reconnectOnCancelled = true, + onError = { onError(it) }, + responseHandler = { response, sendRequest -> + when (response.typeCase) { + RpcEventStreamingService.StreamEventsResponse.TypeCase.PING -> { + val pong = RpcEventStreamingService.StreamEventsRequest.newBuilder() + .setPong( + EventModel.ClientPong.newBuilder() + .setTimestamp(currentTimestamp()) + ) + .build() + + streamRef.receivedPing(updatedTimeout = response.ping.pingDelay.seconds * 1_000L) + sendRequest(pong) + trace(message = "EventStream Pong. Server timestamp: ${response.ping.timestamp}") + } + + RpcEventStreamingService.StreamEventsResponse.TypeCase.EVENTS -> { + response.events.eventsList.forEach { event -> + when (event.typeCase) { + EventModel.Event.TypeCase.CHAT_UPDATE -> { + onEvent(event.chatUpdate.toChatUpdate()) + } + else -> { + trace( + message = "EventStream received unhandled event type: ${event.typeCase}", + type = TraceType.Log, + ) + } + } + } + } + + RpcEventStreamingService.StreamEventsResponse.TypeCase.ERROR -> { + val error = when (response.error.code) { + RpcEventStreamingService.StreamEventsResponse.StreamError.Code.DENIED -> + StreamEventsError.Denied() + RpcEventStreamingService.StreamEventsResponse.StreamError.Code.INVALID_TIMESTAMP -> + StreamEventsError.InvalidTimestamp() + else -> StreamEventsError.Unrecognized() + } + onError(error) + trace( + message = "EventStream error: ${response.error.code}", + type = TraceType.Error, + ) + } + + else -> { + trace( + message = "EventStream received empty message.", + type = TraceType.Error, + ) + } + } + } + ) + } + + private fun currentTimestamp(): Timestamp { + val now = Clock.System.now() + return Timestamp.newBuilder() + .setSeconds(now.epochSeconds) + .setNanos(now.nanosecondsOfSecond) + .build() + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt new file mode 100644 index 000000000..05c8c9988 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt @@ -0,0 +1,67 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.extensions.toChatMessage +import com.flipcash.services.internal.network.services.ChatMessagingService +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.repository.ChatMessagingRepository +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.utils.ErrorUtils + +internal class InternalChatMessagingRepository( + private val service: ChatMessagingService, +) : ChatMessagingRepository { + + override suspend fun getMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result = service.getMessage(owner, chatId, messageId) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun getMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> = service.getMessages(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { messages -> messages.map { it.toChatMessage() } } + + override suspend fun getMessagesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> = service.getMessagesByIds(owner, chatId, messageIds) + .onFailure { ErrorUtils.handleError(it) } + .map { messages -> messages.map { it.toChatMessage() } } + + override suspend fun sendMessage( + owner: KeyPair, + chatId: ChatId, + content: List, + clientMessageId: ClientMessageId, + ): Result = service.sendMessage(owner, chatId, content, clientMessageId) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun advancePointer( + owner: KeyPair, + chatId: ChatId, + pointerType: PointerType, + messageId: Long, + ): Result = service.advancePointer(owner, chatId, pointerType, messageId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun notifyIsTyping( + owner: KeyPair, + chatId: ChatId, + state: TypingState, + ): Result = service.notifyIsTyping(owner, chatId, state) + .onFailure { ErrorUtils.handleError(it) } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt new file mode 100644 index 000000000..85a32d718 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -0,0 +1,37 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.domain.ChatMetadataMapper +import com.flipcash.services.internal.network.extensions.toPagingToken +import com.flipcash.services.internal.network.services.ChatService +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.repository.ChatRepository +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.utils.ErrorUtils + +internal class InternalChatRepository( + private val service: ChatService, + private val mapper: ChatMetadataMapper, +) : ChatRepository { + override suspend fun getChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.getChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { mapper.map(it) } + + override suspend fun getDmChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result = service.getDmChatFeed(owner, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + ChatFeedPage( + chats = response.chatsList.map { mapper.map(it) }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalEventStreamingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalEventStreamingRepository.kt new file mode 100644 index 000000000..49bf9320b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalEventStreamingRepository.kt @@ -0,0 +1,21 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.EventStreamReference +import com.flipcash.services.internal.network.services.EventStreamingService +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.repository.EventStreamingRepository +import com.getcode.ed25519.Ed25519.KeyPair +import kotlinx.coroutines.CoroutineScope + +internal class InternalEventStreamingRepository( + private val service: EventStreamingService, +) : EventStreamingRepository { + override fun openEventStream( + scope: CoroutineScope, + owner: KeyPair, + onEvent: (ChatUpdate) -> Unit, + onError: (Throwable) -> Unit, + ): EventStreamReference { + return service.openEventStream(scope, owner, onEvent, onError) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 0daa6d9da..04e96fb3c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -303,6 +303,84 @@ sealed class GetContactsError( data class Other(override val cause: Throwable? = null) : GetContactsError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetChatError("Denied") + class NotFound : GetChatError("Not found") + class Unrecognized : GetChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetChatError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetDmChatFeedError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetDmChatFeedError("Denied") + class NotFound : GetDmChatFeedError("Not found") + class Unrecognized : GetDmChatFeedError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetDmChatFeedError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetMessageError("Denied") + class NotFound : GetMessageError("Not found") + class Unrecognized : GetMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetMessagesError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetMessagesError("Denied") + class NotFound : GetMessagesError("Not found") + class Unrecognized : GetMessagesError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetMessagesError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class SendMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : SendMessageError("Denied") + class Unrecognized : SendMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SendMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class AdvancePointerError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : AdvancePointerError("Denied") + class MessageNotFound : AdvancePointerError("Message not found") + class Unrecognized : AdvancePointerError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : AdvancePointerError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class NotifyIsTypingError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : NotifyIsTypingError("Denied") + class Unrecognized : NotifyIsTypingError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : NotifyIsTypingError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class StreamEventsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : StreamEventsError("Denied") + class InvalidTimestamp : StreamEventsError("Invalid timestamp") + class Unrecognized : StreamEventsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : StreamEventsError(message = cause?.message, cause = cause), NotifiableError +} + sealed class ResolveContactError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatFeedPage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatFeedPage.kt new file mode 100644 index 000000000..5bd62d3fa --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatFeedPage.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken + +data class ChatFeedPage( + val chats: List, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt new file mode 100644 index 000000000..897a22b48 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatId.kt @@ -0,0 +1,13 @@ +package com.flipcash.services.models.chat + +data class ChatId(val bytes: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChatId) return false + return bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = bytes.contentHashCode() + + override fun toString(): String = "ChatId(${bytes.size} bytes)" +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt new file mode 100644 index 000000000..267b06353 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMember.kt @@ -0,0 +1,10 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID + +data class ChatMember( + val userId: ID, + val userProfile: UserProfile, + val pointers: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt new file mode 100644 index 000000000..3acea6418 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -0,0 +1,12 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class ChatMessage( + val messageId: Long, + val senderId: ID?, + val content: List, + val timestamp: Instant, + val unreadSeq: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt new file mode 100644 index 000000000..fc93f3707 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -0,0 +1,11 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Instant + +data class ChatMetadata( + val chatId: ChatId, + val type: ChatType, + val members: List, + val lastMessage: ChatMessage?, + val lastActivity: Instant, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatType.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatType.kt new file mode 100644 index 000000000..ba707e956 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatType.kt @@ -0,0 +1,6 @@ +package com.flipcash.services.models.chat + +enum class ChatType { + UNKNOWN, + DM, +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt new file mode 100644 index 000000000..6bb655764 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +data class ChatUpdate( + val chatId: ChatId, + val newMessages: List, + val pointerUpdates: List, + val typingNotifications: List, + val metadataUpdates: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ClientMessageId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ClientMessageId.kt new file mode 100644 index 000000000..22b4e8244 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ClientMessageId.kt @@ -0,0 +1,13 @@ +package com.flipcash.services.models.chat + +data class ClientMessageId(val bytes: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ClientMessageId) return false + return bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = bytes.contentHashCode() + + override fun toString(): String = "ClientMessageId(${bytes.size} bytes)" +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt new file mode 100644 index 000000000..1b481b452 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -0,0 +1,5 @@ +package com.flipcash.services.models.chat + +sealed interface MessageContent { + data class Text(val text: String) : MessageContent +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessagePointer.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessagePointer.kt new file mode 100644 index 000000000..b5e583a0b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessagePointer.kt @@ -0,0 +1,16 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID + +data class MessagePointer( + val type: PointerType, + val userId: ID, + val value: Long, +) + +enum class PointerType { + UNKNOWN, + SENT, + DELIVERED, + READ, +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt new file mode 100644 index 000000000..5797b232f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt @@ -0,0 +1,8 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Instant + +sealed interface MetadataUpdate { + data class FullRefresh(val metadata: ChatMetadata) : MetadataUpdate + data class LastActivityChanged(val newLastActivity: Instant) : MetadataUpdate +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/TypingNotification.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/TypingNotification.kt new file mode 100644 index 000000000..ad5a6af5e --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/TypingNotification.kt @@ -0,0 +1,16 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID + +data class TypingNotification( + val userId: ID, + val state: TypingState, +) + +enum class TypingState { + UNKNOWN, + STARTED_TYPING, + STILL_TYPING, + STOPPED_TYPING, + TYPING_TIMED_OUT, +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt new file mode 100644 index 000000000..6b5b350f9 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt @@ -0,0 +1,50 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.getcode.ed25519.Ed25519.KeyPair + +interface ChatMessagingRepository { + suspend fun getMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result + + suspend fun getMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> + + suspend fun getMessagesByIds( + owner: KeyPair, + chatId: ChatId, + messageIds: List, + ): Result> + + suspend fun sendMessage( + owner: KeyPair, + chatId: ChatId, + content: List, + clientMessageId: ClientMessageId, + ): Result + + suspend fun advancePointer( + owner: KeyPair, + chatId: ChatId, + pointerType: PointerType, + messageId: Long, + ): Result + + suspend fun notifyIsTyping( + owner: KeyPair, + chatId: ChatId, + state: TypingState, + ): Result +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt new file mode 100644 index 000000000..4b4600349 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -0,0 +1,19 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.getcode.ed25519.Ed25519.KeyPair + +interface ChatRepository { + suspend fun getChat( + owner: KeyPair, + chatId: ChatId, + ): Result + + suspend fun getDmChatFeed( + owner: KeyPair, + queryOptions: QueryOptions, + ): Result +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/EventStreamingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/EventStreamingRepository.kt new file mode 100644 index 000000000..3bff3beca --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/EventStreamingRepository.kt @@ -0,0 +1,15 @@ +package com.flipcash.services.repository + +import com.flipcash.services.internal.network.services.EventStreamReference +import com.flipcash.services.models.chat.ChatUpdate +import com.getcode.ed25519.Ed25519.KeyPair +import kotlinx.coroutines.CoroutineScope + +interface EventStreamingRepository { + fun openEventStream( + scope: CoroutineScope, + owner: KeyPair, + onEvent: (ChatUpdate) -> Unit, + onError: (Throwable) -> Unit = {}, + ): EventStreamReference +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt new file mode 100644 index 000000000..9c189ce3e --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -0,0 +1,187 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.repository.ChatRepository +import com.flipcash.services.user.UserManager +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.accounts.AccountCluster +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatControllerTest { + + private val repository = FakeChatRepository() + private val userManager = mockk(relaxed = true) + private val controller = ChatController(repository, userManager) + + private fun stubOwner() { + val keyPair = mockk(relaxed = true) + val cluster = mockk(relaxed = true) { + every { authority } returns mockk { every { this@mockk.keyPair } returns keyPair } + } + every { userManager.accountCluster } returns cluster + } + + // region getChat + + @Test + fun `getChat fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.getChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + } + + @Test + fun `getChat forwards the chatId to the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + repository.getChatResult = Result.success(stubMetadata(chatId)) + + controller.getChat(chatId) + + assertEquals(chatId, repository.lastChatId) + } + + @Test + fun `getChat returns the metadata from the repository`() = runTest { + stubOwner() + val chatId = ChatId(ByteArray(32) { 0x42 }) + val expected = stubMetadata(chatId) + repository.getChatResult = Result.success(expected) + + val result = controller.getChat(chatId) + + assertSame(expected, result.getOrThrow()) + } + + @Test + fun `getChat surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("denied") + repository.getChatResult = Result.failure(cause) + + val result = controller.getChat(ChatId(ByteArray(32))) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region getDmChatFeed + + @Test + fun `getDmChatFeed fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.getDmChatFeed() + + assertTrue(result.isFailure) + } + + @Test + fun `getDmChatFeed uses default QueryOptions when none provided`() = runTest { + stubOwner() + repository.getDmChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getDmChatFeed() + + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getDmChatFeed forwards custom query options`() = runTest { + stubOwner() + val token = listOf(0xAB.toByte()) + val options = QueryOptions(limit = 25, token = token, descending = false) + repository.getDmChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getDmChatFeed(options) + + assertEquals(25, repository.lastQueryOptions?.limit) + assertEquals(token, repository.lastQueryOptions?.token) + assertEquals(false, repository.lastQueryOptions?.descending) + } + + @Test + fun `getDmChatFeed returns page with chats and paging state`() = runTest { + stubOwner() + val chat1 = stubMetadata(ChatId(ByteArray(32) { 1 })) + val chat2 = stubMetadata(ChatId(ByteArray(32) { 2 })) + val nextToken = listOf(0xFF.toByte()) + val page = ChatFeedPage( + chats = listOf(chat1, chat2), + pagingToken = nextToken, + hasMore = true, + ) + repository.getDmChatFeedResult = Result.success(page) + + val result = controller.getDmChatFeed() + + val returned = result.getOrThrow() + assertEquals(2, returned.chats.size) + assertEquals(nextToken, returned.pagingToken) + assertTrue(returned.hasMore) + } + + @Test + fun `getDmChatFeed surfaces repository failures without swallowing`() = runTest { + stubOwner() + val cause = RuntimeException("server error") + repository.getDmChatFeedResult = Result.failure(cause) + + val result = controller.getDmChatFeed() + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region helpers + + private fun stubMetadata(chatId: ChatId = ChatId(ByteArray(32))) = ChatMetadata( + chatId = chatId, + type = ChatType.DM, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + ) + + // endregion +} + +// region Fakes + +private class FakeChatRepository : ChatRepository { + var getChatResult: Result = Result.failure(RuntimeException("not configured")) + var getDmChatFeedResult: Result = Result.failure(RuntimeException("not configured")) + var lastChatId: ChatId? = null + var lastQueryOptions: QueryOptions? = null + + override suspend fun getChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return getChatResult + } + + override suspend fun getDmChatFeed(owner: Ed25519.KeyPair, queryOptions: QueryOptions): Result { + lastQueryOptions = queryOptions + return getDmChatFeedResult + } +} + +// endregion diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt new file mode 100644 index 000000000..d19fb609f --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -0,0 +1,238 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.user.UserManager +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.accounts.AccountCluster +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatMessagingControllerTest { + + private val repository = FakeChatMessagingRepository() + private val userManager = mockk(relaxed = true) + private val controller = ChatMessagingController(repository, userManager) + + private val testChatId = ChatId(ByteArray(32) { 0x01 }) + + private fun stubOwner() { + val keyPair = mockk(relaxed = true) + val cluster = mockk(relaxed = true) { + every { authority } returns mockk { every { this@mockk.keyPair } returns keyPair } + } + every { userManager.accountCluster } returns cluster + } + + private fun stubMessage(id: Long = 1, text: String = "hello") = ChatMessage( + messageId = id, + senderId = listOf(1.toByte()), + content = listOf(MessageContent.Text(text)), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = id, + ) + + // region getMessage + + @Test + fun `getMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getMessage(testChatId, 1) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getMessage forwards chatId and messageId`() = runTest { + stubOwner() + val msg = stubMessage(42) + repository.getMessageResult = Result.success(msg) + + controller.getMessage(testChatId, 42) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(42L, repository.lastMessageId) + } + + @Test + fun `getMessage returns the message from repository`() = runTest { + stubOwner() + val msg = stubMessage(1, "world") + repository.getMessageResult = Result.success(msg) + + val result = controller.getMessage(testChatId, 1) + + assertEquals("world", (result.getOrThrow().content.first() as MessageContent.Text).text) + } + + // endregion + + // region sendMessage + + @Test + fun `sendMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.sendMessage(testChatId, listOf(MessageContent.Text("hi")), ClientMessageId(ByteArray(16))) + assertTrue(result.isFailure) + } + + @Test + fun `sendMessage forwards content and clientMessageId`() = runTest { + stubOwner() + val clientId = ClientMessageId(ByteArray(16) { 0xAB.toByte() }) + val content = listOf(MessageContent.Text("test")) + repository.sendMessageResult = Result.success(stubMessage()) + + controller.sendMessage(testChatId, content, clientId) + + assertEquals(content, repository.lastContent) + assertEquals(clientId, repository.lastClientMessageId) + } + + @Test + fun `sendMessage returns server-assigned message`() = runTest { + stubOwner() + val serverMsg = stubMessage(99, "confirmed") + repository.sendMessageResult = Result.success(serverMsg) + + val result = controller.sendMessage(testChatId, listOf(MessageContent.Text("test")), ClientMessageId(ByteArray(16))) + + assertEquals(99L, result.getOrThrow().messageId) + } + + // endregion + + // region advancePointer + + @Test + fun `advancePointer forwards pointer type and message id`() = runTest { + stubOwner() + repository.advancePointerResult = Result.success(Unit) + + controller.advancePointer(testChatId, PointerType.READ, 50) + + assertEquals(PointerType.READ, repository.lastPointerType) + assertEquals(50L, repository.lastMessageId) + } + + @Test + fun `advancePointer surfaces repository failure`() = runTest { + stubOwner() + val cause = RuntimeException("not found") + repository.advancePointerResult = Result.failure(cause) + + val result = controller.advancePointer(testChatId, PointerType.DELIVERED, 10) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region notifyIsTyping + + @Test + fun `notifyIsTyping forwards typing state`() = runTest { + stubOwner() + repository.notifyIsTypingResult = Result.success(Unit) + + controller.notifyIsTyping(testChatId, TypingState.STARTED_TYPING) + + assertEquals(TypingState.STARTED_TYPING, repository.lastTypingState) + } + + // endregion + + // region getMessages + + @Test + fun `getMessages uses default QueryOptions`() = runTest { + stubOwner() + repository.getMessagesResult = Result.success(emptyList()) + + controller.getMessages(testChatId) + + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getMessagesByIds forwards message ids`() = runTest { + stubOwner() + val ids = listOf(1L, 2L, 3L) + repository.getMessagesByIdsResult = Result.success(listOf(stubMessage(1), stubMessage(2), stubMessage(3))) + + val result = controller.getMessagesByIds(testChatId, ids) + + assertEquals(ids, repository.lastMessageIds) + assertEquals(3, result.getOrThrow().size) + } + + // endregion +} + +// region Fakes + +private class FakeChatMessagingRepository : ChatMessagingRepository { + var getMessageResult: Result = Result.failure(RuntimeException("not configured")) + var getMessagesResult: Result> = Result.failure(RuntimeException("not configured")) + var getMessagesByIdsResult: Result> = Result.failure(RuntimeException("not configured")) + var sendMessageResult: Result = Result.failure(RuntimeException("not configured")) + var advancePointerResult: Result = Result.failure(RuntimeException("not configured")) + var notifyIsTypingResult: Result = Result.failure(RuntimeException("not configured")) + + var lastChatId: ChatId? = null + var lastMessageId: Long? = null + var lastMessageIds: List? = null + var lastQueryOptions: QueryOptions? = null + var lastContent: List? = null + var lastClientMessageId: ClientMessageId? = null + var lastPointerType: PointerType? = null + var lastTypingState: TypingState? = null + + override suspend fun getMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result { + lastChatId = chatId; lastMessageId = messageId + return getMessageResult + } + + override suspend fun getMessages(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions): Result> { + lastChatId = chatId; lastQueryOptions = queryOptions + return getMessagesResult + } + + override suspend fun getMessagesByIds(owner: Ed25519.KeyPair, chatId: ChatId, messageIds: List): Result> { + lastChatId = chatId; lastMessageIds = messageIds + return getMessagesByIdsResult + } + + override suspend fun sendMessage(owner: Ed25519.KeyPair, chatId: ChatId, content: List, clientMessageId: ClientMessageId): Result { + lastChatId = chatId; lastContent = content; lastClientMessageId = clientMessageId + return sendMessageResult + } + + override suspend fun advancePointer(owner: Ed25519.KeyPair, chatId: ChatId, pointerType: PointerType, messageId: Long): Result { + lastChatId = chatId; lastPointerType = pointerType; lastMessageId = messageId + return advancePointerResult + } + + override suspend fun notifyIsTyping(owner: Ed25519.KeyPair, chatId: ChatId, state: TypingState): Result { + lastChatId = chatId; lastTypingState = state + return notifyIsTypingResult + } +} + +// endregion diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt new file mode 100644 index 000000000..c844c836b --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/EventStreamingControllerTest.kt @@ -0,0 +1,89 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.internal.network.services.EventStreamReference +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.repository.EventStreamingRepository +import com.flipcash.services.user.UserManager +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.accounts.AccountCluster +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class EventStreamingControllerTest { + + private val repository = FakeEventStreamingRepository() + private val userManager = mockk(relaxed = true) + private val controller = EventStreamingController(repository, userManager) + + private fun stubOwner() { + val keyPair = mockk(relaxed = true) + val cluster = mockk(relaxed = true) { + every { authority } returns mockk { every { this@mockk.keyPair } returns keyPair } + } + every { userManager.accountCluster } returns cluster + } + + @Test + fun `open does nothing when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + controller.open(scope) + + // No stream opened + assert(!repository.opened) + } + + @Test + fun `open creates stream when account cluster exists`() = runTest { + stubOwner() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + controller.open(scope) + + assert(repository.opened) + } + + @Test + fun `close destroys the stream reference`() = runTest { + stubOwner() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + controller.open(scope) + + controller.close() + + assertNotNull(repository.lastStreamRef) + verify { repository.lastStreamRef!!.destroy() } + } + + @Test + fun `chatUpdates SharedFlow is accessible`() { + assertNotNull(controller.chatUpdates) + } +} + +private class FakeEventStreamingRepository : EventStreamingRepository { + var opened = false + var lastStreamRef: EventStreamReference? = null + + override fun openEventStream( + scope: CoroutineScope, + owner: Ed25519.KeyPair, + onEvent: (ChatUpdate) -> Unit, + onError: (Throwable) -> Unit, + ): EventStreamReference { + opened = true + val ref = mockk(relaxed = true) + lastStreamRef = ref + return ref + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt new file mode 100644 index 000000000..9e07ccbc9 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.services.internal.domain + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.codeinc.flipcash.gen.profile.v1.Model as ProfileModel +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.PointerType +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ChatMetadataMapperTest { + + private val mapper = ChatMetadataMapper( + userProfileMapper = UserProfileMapper(socialMapper = SocialAccountMapper()), + ) + + private fun chatId(byte: Byte = 1): Common.ChatId = + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { byte })) + .build() + + private fun userId(byte: Byte = 2): Common.UserId = + Common.UserId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(16) { byte })) + .build() + + private fun messageId(value: Long = 1): MessagingModel.MessageId = + MessagingModel.MessageId.newBuilder().setValue(value).build() + + private fun textContent(text: String = "hello"): MessagingModel.Content = + MessagingModel.Content.newBuilder() + .setText(MessagingModel.TextContent.newBuilder().setText(text)) + .build() + + private fun message( + id: Long = 1, + text: String = "hello", + ): MessagingModel.Message = MessagingModel.Message.newBuilder() + .setMessageId(messageId(id)) + .setSenderId(userId()) + .addContent(textContent(text)) + .setTs(Timestamp.newBuilder().setSeconds(1000)) + .setUnreadSeq(1) + .build() + + private fun member( + userIdByte: Byte = 2, + displayName: String = "User", + ): ChatModel.Member = ChatModel.Member.newBuilder() + .setUserId(userId(userIdByte)) + .setUserProfile(ProfileModel.UserProfile.newBuilder().setDisplayName(displayName)) + .addPointers( + MessagingModel.Pointer.newBuilder() + .setType(MessagingModel.Pointer.Type.READ) + .setUserId(userId(userIdByte)) + .setValue(messageId(5)) + ) + .build() + + private fun metadata( + block: ChatModel.Metadata.Builder.() -> Unit = {}, + ): ChatModel.Metadata = ChatModel.Metadata.newBuilder() + .setChatId(chatId()) + .setType(ChatModel.Metadata.ChatType.DM) + .setLastActivity(Timestamp.newBuilder().setSeconds(2000)) + .apply(block) + .build() + + @Test + fun `maps chat type DM`() { + val result = mapper.map(metadata()) + assertEquals(ChatType.DM, result.type) + } + + @Test + fun `maps chat id bytes`() { + val result = mapper.map(metadata()) + assertEquals(32, result.chatId.bytes.size) + } + + @Test + fun `maps last activity`() { + val result = mapper.map(metadata()) + assertEquals(2000L, result.lastActivity.epochSeconds) + } + + @Test + fun `maps members with pointers`() { + val result = mapper.map(metadata { addMembers(member()) }) + assertEquals(1, result.members.size) + assertEquals("User", result.members[0].userProfile.displayName) + assertEquals(1, result.members[0].pointers.size) + assertEquals(PointerType.READ, result.members[0].pointers[0].type) + assertEquals(5L, result.members[0].pointers[0].value) + } + + @Test + fun `maps last message when present`() { + val result = mapper.map(metadata { setLastMessage(message(text = "hey")) }) + assertNotNull(result.lastMessage) + assertEquals(1L, result.lastMessage!!.messageId) + } + + @Test + fun `last message null when absent`() { + val result = mapper.map(metadata()) + assertNull(result.lastMessage) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt index d9643f709..d7dc58b99 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt @@ -127,6 +127,111 @@ class ErrorsTest { assertIs(PlacePoolBetError.MaxBetsReceived()) } + // -- GetMessageError -- + + @Test + fun `GetMessageError subtypes are CodeServerError`() { + assertIs(GetMessageError.Denied()) + assertIs(GetMessageError.NotFound()) + assertIs(GetMessageError.Unrecognized()) + assertIs(GetMessageError.Other()) + } + + // -- GetMessagesError -- + + @Test + fun `GetMessagesError subtypes are CodeServerError`() { + assertIs(GetMessagesError.Denied()) + assertIs(GetMessagesError.NotFound()) + assertIs(GetMessagesError.Unrecognized()) + assertIs(GetMessagesError.Other()) + } + + // -- SendMessageError -- + + @Test + fun `SendMessageError subtypes are CodeServerError`() { + assertIs(SendMessageError.Denied()) + assertIs(SendMessageError.Unrecognized()) + assertIs(SendMessageError.Other()) + } + + // -- AdvancePointerError -- + + @Test + fun `AdvancePointerError has expected variants`() { + assertEquals("Denied", AdvancePointerError.Denied().message) + assertEquals("Message not found", AdvancePointerError.MessageNotFound().message) + assertIs(AdvancePointerError.Unrecognized()) + } + + // -- NotifyIsTypingError -- + + @Test + fun `NotifyIsTypingError subtypes are CodeServerError`() { + assertIs(NotifyIsTypingError.Denied()) + assertIs(NotifyIsTypingError.Unrecognized()) + assertIs(NotifyIsTypingError.Other()) + } + + // -- GetChatError -- + + @Test + fun `GetChatError subtypes are CodeServerError`() { + assertIs(GetChatError.Denied()) + assertIs(GetChatError.NotFound()) + assertIs(GetChatError.Unrecognized()) + assertIs(GetChatError.Other()) + } + + @Test + fun `GetChatError has expected messages`() { + assertEquals("Denied", GetChatError.Denied().message) + assertEquals("Not found", GetChatError.NotFound().message) + } + + // -- GetDmChatFeedError -- + + @Test + fun `GetDmChatFeedError subtypes are CodeServerError`() { + assertIs(GetDmChatFeedError.Denied()) + assertIs(GetDmChatFeedError.NotFound()) + assertIs(GetDmChatFeedError.Unrecognized()) + assertIs(GetDmChatFeedError.Other()) + } + + @Test + fun `GetDmChatFeedError Other preserves cause`() { + val root = RuntimeException("feed broke") + val error = GetDmChatFeedError.Other(root) + assertSame(root, error.cause) + assertEquals("feed broke", error.message) + } + + // -- StreamEventsError -- + + @Test + fun `StreamEventsError subtypes are CodeServerError`() { + assertIs(StreamEventsError.Denied()) + assertIs(StreamEventsError.InvalidTimestamp()) + assertIs(StreamEventsError.Unrecognized()) + assertIs(StreamEventsError.Other()) + } + + @Test + fun `StreamEventsError has expected messages`() { + assertEquals("Denied", StreamEventsError.Denied().message) + assertEquals("Invalid timestamp", StreamEventsError.InvalidTimestamp().message) + } + + @Test + fun `StreamEventsError Other preserves cause`() { + val root = RuntimeException("stream broke") + val error = StreamEventsError.Other(root) + assertSame(root, error.cause) + assertEquals("stream broke", error.message) + } + // -- GetJwtError -- @Test diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/ChatIdTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/ChatIdTest.kt new file mode 100644 index 000000000..0b754b6fc --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/ChatIdTest.kt @@ -0,0 +1,43 @@ +package com.flipcash.services.models.chat + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ChatIdTest { + + @Test + fun `equals returns true for same byte content`() { + val a = ChatId(byteArrayOf(1, 2, 3)) + val b = ChatId(byteArrayOf(1, 2, 3)) + assertEquals(a, b) + } + + @Test + fun `equals returns false for different byte content`() { + val a = ChatId(byteArrayOf(1, 2, 3)) + val b = ChatId(byteArrayOf(4, 5, 6)) + assertNotEquals(a, b) + } + + @Test + fun `hashCode is consistent for equal instances`() { + val a = ChatId(byteArrayOf(10, 20, 30)) + val b = ChatId(byteArrayOf(10, 20, 30)) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `toString includes byte size`() { + val id = ChatId(ByteArray(32)) + assertTrue(id.toString().contains("32 bytes")) + } + + @Test + fun `equals returns false for non-ChatId`() { + val id = ChatId(byteArrayOf(1, 2, 3)) + @Suppress("AssertBetweenInconvertibleTypes") + assertNotEquals(id, "not a ChatId") + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt new file mode 100644 index 000000000..3e80d6471 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt @@ -0,0 +1,99 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.UserProfile +import kotlin.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class DomainModelsTest { + + @Test + fun `ChatType has expected values`() { + assertEquals(2, ChatType.entries.size) + assertIs(ChatType.UNKNOWN) + assertIs(ChatType.DM) + } + + @Test + fun `PointerType has expected values`() { + assertEquals(4, PointerType.entries.size) + assertIs(PointerType.UNKNOWN) + assertIs(PointerType.SENT) + assertIs(PointerType.DELIVERED) + assertIs(PointerType.READ) + } + + @Test + fun `TypingState has expected values`() { + assertEquals(5, TypingState.entries.size) + } + + @Test + fun `MessageContent Text holds text`() { + val content = MessageContent.Text("hello") + assertEquals("hello", content.text) + } + + @Test + fun `ChatMessage can have null senderId`() { + val msg = ChatMessage( + messageId = 1, + senderId = null, + content = listOf(MessageContent.Text("system")), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + assertNull(msg.senderId) + } + + @Test + fun `ChatUpdate aggregates all update types`() { + val chatId = ChatId(ByteArray(32)) + val update = ChatUpdate( + chatId = chatId, + newMessages = listOf( + ChatMessage(1, null, listOf(MessageContent.Text("hi")), Instant.fromEpochSeconds(0), 1) + ), + pointerUpdates = listOf( + MessagePointer(PointerType.READ, listOf(1.toByte()), 5) + ), + typingNotifications = listOf( + TypingNotification(listOf(1.toByte()), TypingState.STARTED_TYPING) + ), + metadataUpdates = listOf( + MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(100)) + ), + ) + + assertEquals(1, update.newMessages.size) + assertEquals(1, update.pointerUpdates.size) + assertEquals(1, update.typingNotifications.size) + assertEquals(1, update.metadataUpdates.size) + } + + @Test + fun `MetadataUpdate FullRefresh holds metadata`() { + val metadata = ChatMetadata( + chatId = ChatId(ByteArray(32)), + type = ChatType.DM, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(500), + ) + val update = MetadataUpdate.FullRefresh(metadata) + assertEquals(metadata, update.metadata) + } + + @Test + fun `ChatMember holds profile and pointers`() { + val member = ChatMember( + userId = listOf(1.toByte()), + userProfile = UserProfile("Test", emptyList(), null, null), + pointers = listOf(MessagePointer(PointerType.READ, listOf(1.toByte()), 10)), + ) + assertEquals("Test", member.userProfile.displayName) + assertEquals(1, member.pointers.size) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/ID.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/ID.kt index f7b97b442..b277c9bb3 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/ID.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/ID.kt @@ -1,7 +1,5 @@ package com.getcode.opencode.model.core -import com.getcode.opencode.utils.generate -import com.getcode.solana.keys.PublicKey import com.getcode.utils.hexEncodedString import java.nio.ByteBuffer import java.util.UUID @@ -10,7 +8,7 @@ typealias ID = List val NoId: ID = emptyList() -val RandomId: ID = PublicKey.generate().bytes.toList() +val RandomId: ID get() = UUID.randomUUID().bytes val ID.uuid: UUID? get() { diff --git a/settings.gradle.kts b/settings.gradle.kts index 72bfc574b..3031ec523 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,6 +50,7 @@ include( ":apps:flipcash:shared:activityfeed", ":apps:flipcash:shared:bills", ":apps:flipcash:shared:bill-customization", + ":apps:flipcash:shared:chat", ":apps:flipcash:shared:contacts", ":apps:flipcash:shared:currency-creator", ":apps:flipcash:shared:onramp:coinbase",