From 52a75b2745a49d83a5aa0ff375d88388bcca3b36 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 12:52:59 -0400 Subject: [PATCH 1/2] feat(chat): name a person by their handle when they have no display name A TIP_DM counterparty is identified by their server profile, and a display name is not required to hold one: onboarding only forces a name for new accounts, and "Change Username" is reachable with `includeName = false`. FeedSyncDelegate keeps those name-less tip DMs in the feed on purpose. Every surface that named the person by `displayName` alone rendered an empty string with a blank avatar. `nameOrHandle(displayName, handle)` in `Handle.kt` is the one rule: the display name when there is one, the `@handle` when there isn't, null when there is neither. It backs `ChatParticipant.name`, `ConversationReference.name` and `BlockedUserProfile.name`, so the messenger, the tips list and the blocklist agree without repeating `?: handle` at each call site. The tips row, the top bar and the blocklist row are single-line, so the handle takes the name's place there; the profile sheet gains a handle line under the name, matching the info card (node 9443:8928), dropped when the name above already is the handle. `InitialsText` strips the `@` so a handle-only account gets its own first letter rather than a shared "@" avatar. The handle never reached any of this. `ProtobufToLocal` sets `username` on the wire model, but the chat cache dropped it: `UserProfileEntity` had no column, `UserProfileSerialized` had no field, and both the feed and the open conversation read members from Room rather than the fetch response. `UserProfile.handle` was therefore null everywhere, including on the info card line shipped in #1330. Carrying `username` through the entity, the serialized form, both mappers and `upsertNameAndAvatar` takes the database to 31 with a nullable-column auto-migration, the same shape as 26 -> 27 and 29 -> 30. --- .../app/core/blocklist/BlockedUserProfile.kt | 13 +- .../flipcash/app/core/chat/ChatParticipant.kt | 10 + .../internal/screens/components/ChatTopBar.kt | 4 +- .../components/ContactInfoContainer.kt | 15 +- .../screens/profile/ChatProfileScreen.kt | 15 +- .../screens/profile/ChatProfileViewModel.kt | 6 +- .../blocklist/BlocklistScreenContent.kt | 5 +- .../internal/blocklist/BlocklistViewModel.kt | 2 +- .../tipping/internal/components/TipChatRow.kt | 6 +- .../shared/chat/ui/ChatSummaryMapping.kt | 5 + .../shared/chat/ui/ConversationReference.kt | 16 +- .../shared/common/ui/ContactAvatar.kt | 6 +- .../31.json | 768 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../converters/ChatTypeConverters.kt | 3 + .../app/persistence/dao/UserProfileDao.kt | 3 +- .../persistence/entities/UserProfileEntity.kt | 3 + .../entities/UserProfileEntityMapping.kt | 1 + .../persistence/UserProfileMigrationTest.kt | 12 + .../mapper/UserProfileDomainMappers.kt | 1 + .../BlockedUserEntityToProfileMapper.kt | 2 + .../sources/mapper/chat/ChatEntityMapper.kt | 1 + .../mapper/chat/ChatEntityMapperTest.kt | 35 + .../com/flipcash/services/models/Handle.kt | 16 + .../flipcash/services/models/HandleTest.kt | 24 + 25 files changed, 959 insertions(+), 16 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt index d76833a358..9ca5f957bd 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.blocklist import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.nameOrHandle import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -11,12 +12,22 @@ import kotlin.time.Instant * * @param userId The blocked user * @param displayName Resolved display name, or empty if the profile could not be resolved + * @param handle Resolved public `@handle`, or null if unclaimed/unresolved * @param profilePicture Resolved avatar, or null if unset/unresolved * @param blockedAt When the user was blocked */ data class BlockedUserProfile( val userId: ID, val displayName: String, + val handle: String?, val profilePicture: MediaItem?, val blockedAt: Instant, -) +) { + /** + * What to call this person: [displayName] when they have one, [handle] when they don't. + * + * Blocking is reachable from a tip DM, so a blocked account need never have had a name — the + * same rule the chat surfaces use ([com.flipcash.app.core.chat.ChatParticipant.name]). + */ + val name: String? get() = nameOrHandle(displayName, handle) +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt index 1574943a9f..930ebb662f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle import com.getcode.opencode.model.core.ID import kotlinx.parcelize.Parcelize @@ -31,6 +32,15 @@ sealed interface ChatParticipant: Parcelable { */ val handle: String? + /** + * What to call this person — the one rule every surface that names them uses. + * + * [displayName] when they have one, [handle] when they don't. Null only when they have + * neither, which for a [TipUser] means a profile the server sent us nothing identifying for, + * and for a [Contact] means a device contact with an empty name. + */ + val name: String? get() = nameOrHandle(displayName, handle) + data class Contact(val contact: DeviceContact) : ChatParticipant { override val displayName: String get() = contact.displayName override val handle: String? get() = null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 4543ddc97a..b3588c83de 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -83,7 +83,9 @@ internal fun ChatTopBar( Text( modifier = Modifier.weight(1f), - text = state.participant?.displayName.orEmpty(), + // Name-or-handle: the bar is one line (node 9443:9094), and the handle is + // the only identity a name-less tip DM counterparty has. + text = state.participant?.name.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 977b76e14d..3a5285b3df 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -81,7 +81,7 @@ internal fun ContactInfoContainer( ) { Text( modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier, - text = participant?.displayName.orEmpty(), + text = participant?.name.orEmpty(), autoSize = TextAutoSize.StepBased( minFontSize = CodeTheme.typography.textSmall.fontSize, maxFontSize = CodeTheme.typography.textLarge.fontSize, @@ -103,8 +103,9 @@ internal fun ContactInfoContainer( // The line under the name says how this person is addressed: a tip DM by their public // handle (node 9443:8928), a contact DM by the number the chat is keyed on. Never both — - // only one of the two identity sources backs any given conversation. - val handle = participant?.handle + // only one of the two identity sources backs any given conversation. Dropped when the name + // above already *is* the handle, so a name-less account doesn't show it twice. + val handle = participant?.handle?.takeIf { it != participant.name } if (handle != null) { Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), @@ -277,6 +278,13 @@ private fun Preview_AllStates() { ), ) + // A tip DM's counterparty who never set a name: the handle is their whole identity, so it + // takes the name line and the line beneath it is dropped. + val handleOnlyUser = ChatParticipant.TipUser( + userId = listOf(2.toByte()), + profile = UserProfile.Empty.copy(username = "sally_streamer"), + ) + // Fixed width so every state renders at the same size regardless of name/number length. val cardWidth = Modifier.width(300.dp) Column( @@ -286,6 +294,7 @@ private fun Preview_AllStates() { ContactInfoContainer(participant = knownContact, modifier = cardWidth) ContactInfoContainer(participant = unknownContact, modifier = cardWidth) ContactInfoContainer(participant = tipUser, modifier = cardWidth) + ContactInfoContainer(participant = handleOnlyUser, modifier = cardWidth) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 350eeeda3c..c4469fc435 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -97,12 +97,25 @@ private fun ProfileHeader( ) Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), - text = participant?.displayName.orEmpty(), + text = participant?.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + // The handle sits under the name, the same shape as the info card's identity line + // (node 9443:8928). Left out when the line above is already the handle, so a name-less + // account doesn't read it twice. + participant?.handle?.takeIf { it != participant.name }?.let { handle -> + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = handle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } joinDate?.let { instant -> Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 6377c1d3c2..67bcc60626 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -93,7 +93,11 @@ internal class ChatProfileViewModel @Inject constructor( .filterIsInstance() .onEach { participant -> BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_blockUser, participant.displayName), + // "Block ?" is what this read for an account with no display name. + title = resources.getString( + R.string.prompt_title_blockUser, + participant.name.orEmpty(), + ), message = resources.getString(R.string.prompt_description_blockUser), actions = listOf( BottomBarAction( diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt index 10e5a2c06c..92709fbb80 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt @@ -112,7 +112,7 @@ private fun BlockedUserRow( ) { ContactAvatar( image = user.profilePicture, - displayName = user.displayName, + displayName = user.name.orEmpty(), // Blocked users are shown obscured, per the design. blurred = true, modifier = Modifier @@ -121,7 +121,8 @@ private fun BlockedUserRow( ) Text( modifier = Modifier.weight(1f), - text = user.displayName, + // One line, so the handle stands in for a missing name rather than sitting under it. + text = user.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, maxLines = 1, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt index 93b2580f83..f75d9e8ab5 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt @@ -60,7 +60,7 @@ internal class BlocklistViewModel @Inject constructor( BottomBarManager.showMessage( title = resources.getString( R.string.prompt_title_unblockUser, - event.user.displayName, + event.user.name.orEmpty(), ), message = resources.getString(R.string.prompt_description_unblockUser), actions = listOf( diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index ba7fcebf7c..6aab7b47e2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -27,7 +27,7 @@ internal fun TipChatRow( avatar = { ContactAvatar( image = chat.image, - displayName = chat.displayName.orEmpty(), + displayName = chat.name.orEmpty(), modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), @@ -36,7 +36,9 @@ internal fun TipChatRow( title = { Text( modifier = Modifier.weight(1f), - text = chat.displayName.orEmpty(), + // Name, or the `@handle` when there isn't one — the row's single line of identity + // (node 9442:103645 has the preview under it, so there is nowhere else to put it). + text = chat.name.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 28833324a0..46c1ec822d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token @@ -17,6 +18,9 @@ import com.getcode.util.resources.ResourceHelper * is taken from the chat member that isn't [selfId] — used directly by rows with no * separate contact (e.g. tip DMs); the send flow ignores those in favour of its matched * device contact. + * + * The handle rides along with the name because a tip DM counterparty need not have a display + * name; [ConversationReference.name] is what rows should render. */ fun ChatSummary.toConversationReference( selfId: ID?, @@ -27,6 +31,7 @@ fun ChatSummary.toConversationReference( return ConversationReference( chatId = metadata.chatId, displayName = other?.userProfile?.displayName, + handle = other?.userProfile?.handle, image = other?.userProfile?.profilePicture, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 928717b37e..670c74a2ac 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.nameOrHandle import kotlin.time.Instant /** Presentation state derived from an existing DM with a contact. */ @@ -9,6 +10,11 @@ data class ConversationReference( val chatId: ChatId, /** Counterparty display name — used when the row has no separate contact (e.g. tip DMs). */ val displayName: String? = null, + /** + * Counterparty public `@handle`, or null when they haven't claimed one. Only tip DMs carry + * one — a contact DM's counterparty is addressed by phone number. + */ + val handle: String? = null, /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ val image: MediaItem? = null, val lastMessagePreview: String? = null, @@ -16,4 +22,12 @@ data class ConversationReference( val lastActivity: Instant? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, -) \ No newline at end of file +) { + /** + * What to call the counterparty: [displayName] when they have one, [handle] when they don't. + * + * The same rule [com.flipcash.app.core.chat.ChatParticipant.name] applies in the messenger, so a + * tip DM reads the same in the list as it does once opened. + */ + val name: String? get() = nameOrHandle(displayName, handle) +} \ No newline at end of file diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 8afa2d8132..3251205439 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -35,6 +35,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.placeholder import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.HandlePrefix import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem import com.getcode.theme.CodeTheme @@ -272,7 +273,10 @@ private fun UnknownContactAvatar( @Composable private fun BoxWithConstraintsScope.InitialsText(displayName: String) { val initials = remember(displayName) { - displayName.split(" ") + // Callers pass a name-or-handle, so strip the `@` first — otherwise every handle-only + // account gets the same "@" avatar instead of its own first letter. + displayName.removePrefix(HandlePrefix) + .split(" ") .take(2) .mapNotNull { it.firstOrNull()?.uppercaseChar() } .joinToString("") diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json new file mode 100644 index 0000000000..e75f0ba6be --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json @@ -0,0 +1,768 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "7d43a42edcd48f8d5e52fd60b25b32d1", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "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, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "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, '7d43a42edcd48f8d5e52fd60b25b32d1')" + ] + } +} \ 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 49a51a7772..fc4276eac8 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 @@ -91,8 +91,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 27, to = 28), // tokens.market_cap_metrics (nullable) AutoMigration(from = 28, to = 29, spec = FlipcashDatabase.Migration28To29::class), AutoMigration(from = 29, to = 30), // chat_metadata.analytics_counted_through + AutoMigration(from = 30, to = 31), // user_profiles.username (nullable) ], - version = 30, + version = 31, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { 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 index f6188de758..5af3e3dff3 100644 --- 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 @@ -78,6 +78,7 @@ class ChatTypeConverters { VerifiableContactMethod(address, verified = true) }, profilePicture = compat.profilePicture, + username = compat.username, ) }.getOrNull() } @@ -194,6 +195,7 @@ data class UserProfileSerialized( val phoneNumber: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, val profilePicture: MediaItem? = null, + val username: String? = null, ) /** @@ -211,6 +213,7 @@ private data class UserProfileSerializedCompat( val verifiedPhoneNumber: String? = null, val verifiedEmailAddress: String? = null, val profilePicture: MediaItem? = null, + val username: String? = null, ) @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt index 471a0f8cfa..487be14087 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -35,7 +35,7 @@ interface UserProfileDao { INSERT OR REPLACE INTO user_profiles ( user_id_hex, display_name, phone_value, phone_verified, email_value, email_verified, social_accounts_json, - profile_picture_json, pending_migration_json + profile_picture_json, username, pending_migration_json ) VALUES ( :userIdHex, :displayName, @@ -45,6 +45,7 @@ interface UserProfileDao { (SELECT email_verified FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT social_accounts_json FROM user_profiles WHERE user_id_hex = :userIdHex), COALESCE(:profilePicture, (SELECT profile_picture_json FROM user_profiles WHERE user_id_hex = :userIdHex)), + (SELECT username FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = :userIdHex) ) """ diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt index ab28cd621f..4cbab76d79 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt @@ -33,5 +33,8 @@ data class UserProfileEntity( @ColumnInfo(name = "email_verified") val emailVerified: Boolean?, @ColumnInfo(name = "social_accounts_json") val socialAccounts: List?, @ColumnInfo(name = "profile_picture_json") val profilePicture: MediaItem?, + // The public `@handle`, bare (no `@` — that is presentation, added by + // [com.flipcash.services.models.handle]). Null when the user hasn't claimed one. + @ColumnInfo(name = "username") val username: String? = null, @ColumnInfo(name = "pending_migration_json") val pendingMigrationJson: String? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt index f759f4c788..0a29cf9c80 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt @@ -24,6 +24,7 @@ fun UserProfileEntity.toSerialized(): UserProfileSerialized { phoneNumber = phoneValue?.let { VerifiableContactMethod(it, phoneVerified ?: false) }, email = emailValue?.let { VerifiableContactMethod(it, emailVerified ?: false) }, profilePicture = profilePicture, + username = username, ) } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt index e4285b4664..7e876c8409 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt @@ -170,6 +170,7 @@ class UserProfileDaoTest { emailValue = "a@b.com", emailVerified = false, socialAccounts = emptyList(), profilePicture = MediaItem(renditions = emptyList()), + username = "alice", ) @Test @@ -188,6 +189,17 @@ class UserProfileDaoTest { } } + @Test + fun `partial name+avatar write preserves the cached username`() = runBlocking { + val dao = db.userProfileDao() + dao.upsertFull(listOf(fullProfile("u1"))) + + // The blocklist has no username to write; the handle must survive its sync. + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null) + + assertEquals("alice", dao.getByUserId("u1")?.username) + } + @Test fun `chat member relation joins the shared normalized profile`() = runBlocking { db.userProfileDao().upsertFull(listOf(fullProfile("u1"))) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt index b0f585939e..7100900e29 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt @@ -16,6 +16,7 @@ fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( phoneNumber = phoneNumber, email = email, profilePicture = profilePicture, + username = username, ) fun SocialAccountSerialized.toDomain(): SocialAccount = when (this) { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt index 2bfed1fb63..bc196a23a6 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt @@ -3,6 +3,7 @@ package com.flipcash.app.persistence.sources.mapper.blocklist import com.flipcash.app.core.blocklist.BlockedUserProfile import com.flipcash.app.persistence.entities.BlockedUserWithProfile import com.flipcash.app.persistence.entities.toSerialized +import com.flipcash.services.models.asHandle import com.getcode.opencode.model.core.ID import com.getcode.opencode.mapper.Mapper import javax.inject.Inject @@ -17,6 +18,7 @@ class BlockedUserEntityToProfileMapper @Inject constructor() : return BlockedUserProfile( userId = from.blocked.userIdHex.hexToId(), displayName = profile?.displayName.orEmpty(), + handle = profile?.username?.takeIf { it.isNotBlank() }?.asHandle(), profilePicture = profile?.profilePicture, blockedAt = Instant.fromEpochMilliseconds(from.blocked.blockedAtEpochMs), ) 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 index c9e6e6f24a..3f92ad34e5 100644 --- 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 @@ -175,6 +175,7 @@ class ChatEntityMapper @Inject constructor() { emailVerified = profile.email?.verified, socialAccounts = profile.socialAccounts.map { it.toSerialized() }, profilePicture = profile.profilePicture, + username = profile.username, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 9a31dc7d71..028590e92c 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -1,7 +1,12 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMetadataEntity +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.handle import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import org.junit.Assert.assertEquals @@ -57,6 +62,36 @@ class ChatEntityMapperTest { assertEquals(0L, metadata.latestEventSequence) } + /** + * The handle a tip DM falls back to when its counterparty never set a name only reaches the UI + * through this cache — both the feed and the open conversation read members from Room, not from + * the wire response. Dropping the username here made [UserProfile.handle] null everywhere. + */ + @Test + fun `a member's username survives the round trip through the profile row`() { + val member = ChatMember( + userId = listOf(0xAB.toByte()), + userProfile = UserProfile.Empty.copy(displayName = "", username = "sally_streamer"), + pointers = emptyList(), + ) + + val profileRow = mapper.toProfileEntity(member) + assertEquals("sally_streamer", profileRow.username) + + val readBack = mapper.toMember( + ChatMemberWithProfile( + member = ChatMemberEntity( + chatIdHex = CHAT_HEX, + userIdHex = profileRow.userIdHex, + pointersJson = null, + ), + profile = profileRow, + ) + ) + + assertEquals("@sally_streamer", readBack.userProfile.handle) + } + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt index e39d54a624..e45000ad21 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt @@ -48,3 +48,19 @@ val UserProfile.handle: String? /** The linked X account's handle, shown the same way a Flipcash one is. */ val SocialAccount.TwitterX.handle: String get() = username.asHandle() + +/** + * How a person is named: their display name when they have one, their `@handle` when they don't. + * + * A `TIP_DM` counterparty is only ever identified by their server profile, and a display name is not + * required to hold one — `FeedSyncDelegate.feed` keeps name-less tip DMs in the feed on purpose, + * where a `CONTACT_DM` without an identity is dropped. Every surface that named such a person by + * `displayName` alone rendered an empty string. The handle is public and stable, so it is the + * identity to fall back to, and for some of these accounts it is the only one there is. + * + * Blank counts as absent on both sides, so a profile carrying `""` reads the same as one carrying + * nothing. Returns null only when the person has neither — a contact DM's counterparty has no handle + * by design, so for those this is just the display name. + */ +fun nameOrHandle(displayName: String?, handle: String?): String? = + displayName?.takeIf { it.isNotBlank() } ?: handle?.takeIf { it.isNotBlank() } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt index 979788d578..87c2b4c77c 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt @@ -90,4 +90,28 @@ class HandleTest { fun `a claimed username reads as a handle`() { assertEquals("@sally_streamer", UserProfile.Empty.copy(username = "sally_streamer").handle) } + + @Test + fun `a display name wins over a handle`() { + assertEquals("Grace Hopper", nameOrHandle("Grace Hopper", "@grace_hopper")) + } + + @Test + fun `the handle stands in when there is no display name`() { + assertEquals("@sally_streamer", nameOrHandle(null, "@sally_streamer")) + assertEquals("@sally_streamer", nameOrHandle("", "@sally_streamer")) + assertEquals("@sally_streamer", nameOrHandle(" ", "@sally_streamer")) + } + + @Test + fun `neither leaves nothing to render`() { + assertNull(nameOrHandle(null, null)) + assertNull(nameOrHandle("", "")) + assertNull(nameOrHandle(" ", " ")) + } + + @Test + fun `a contact DM keeps its name with no handle to fall back to`() { + assertEquals("Ada Lovelace", nameOrHandle("Ada Lovelace", null)) + } } From d9adaf92e36bb7c97e1c7ffd50ecd3af9f8e6c2d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 13:13:21 -0400 Subject: [PATCH 2/2] test(chat): capture the name-or-handle rule to PNGs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five surfaces this branch changed had no way to be looked at short of an emulator and a live tip DM from a name-less account, so the handle line and the handle-as-name case went in unrendered. These render each surface in every identity state — contact, tip user with a name, tip user with only a handle — to `build/screenshots/`. They assert nothing; they exist so the layouts can be eyeballed. Mechanics are copied from TokenCardWatermarkScreenshotTest: Robolectric with native graphics, clock paused, a fixed number of frames pumped, then the content view drawn directly, which avoids captureToImage()'s waitForIdle hanging on a composable that keeps scheduling frames. The bitmap is cropped to the drawn area, since the previews wrap their content but the content view is the full device. ProfileHeader goes from private to @VisibleForTesting internal so the messenger test can render it. --- .../features/messenger/build.gradle.kts | 1 + .../screens/profile/ChatProfileScreen.kt | 4 +- .../internal/ChatIdentityScreenshotTest.kt | 191 ++++++++++++++++++ .../blocklist/BlocklistScreenshotTest.kt | 111 ++++++++++ .../features/tipping/build.gradle.kts | 2 + .../internal/TipChatRowScreenshotTest.kt | 115 +++++++++++ 6 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt create mode 100644 apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 516415afc9..7047aac358 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -28,4 +28,5 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) + testImplementation(libs.robolectric) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index c4469fc435..c433de17f9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -79,8 +80,9 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { } } +@VisibleForTesting @Composable -private fun ProfileHeader( +internal fun ProfileHeader( participant: ChatParticipant?, joinDate: Instant?, modifier: Modifier = Modifier, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt new file mode 100644 index 0000000000..562ff695ab --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -0,0 +1,191 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.messenger.internal.screens.components.ContactInfoContainer +import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.getcode.navigation.core.CodeNavigator +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the DM info card in each identity state to a PNG so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + * + * Same mechanics as `TokenCardWatermarkScreenshotTest`: pause the clock, pump a fixed number of + * frames, and draw the Android view directly, so a composable that keeps scheduling frames can't + * hang `captureToImage()`'s implicit `waitForIdle`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi") +class ChatIdentityScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + // A saved device contact: name + number, handle line never applies. + private val knownContact = ChatParticipant.Contact( + DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + ) + + // A tip DM counterparty with both: name on top, handle underneath. + private val namedTipUser = ChatParticipant.TipUser( + userId = listOf(1.toByte()), + profile = UserProfile.Empty.copy( + displayName = "Grace Hopper", + username = "grace_hopper", + ), + ) + + // A tip DM counterparty who never set a name — the case this change fixes. Before, every + // surface below rendered them as an empty string. + private val handleOnlyTipUser = ChatParticipant.TipUser( + userId = listOf(2.toByte()), + profile = UserProfile.Empty.copy(username = "sally_streamer"), + ) + + @Test + fun rendersInfoCardIdentityStates() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val cardWidth = Modifier.width(300.dp) + ContactInfoContainer(participant = knownContact, modifier = cardWidth) + ContactInfoContainer(participant = namedTipUser, modifier = cardWidth) + ContactInfoContainer(participant = handleOnlyTipUser, modifier = cardWidth) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_info_card_identity.png") + } + + @Test + fun rendersTopBarIdentityStates() { + val navigator = mockk(relaxed = true) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + participant = participant, + chatType = if (participant is ChatParticipant.TipUser) { + ChatType.TIP_DM + } else { + ChatType.CONTACT_DM + }, + ), + chatActionHandler = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_top_bar_identity.png") + } + + @Test + fun rendersProfileHeaderIdentityStates() { + val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant -> + ProfileHeader( + participant = participant, + joinDate = joinDate, + modifier = Modifier.width(300.dp), + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_profile_header_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** + * The content view is the full device, but the previews wrap their content — crop away the + * untouched (fully transparent) margin so the PNG is just what was composed. + */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt new file mode 100644 index 0000000000..d41d749243 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt @@ -0,0 +1,111 @@ +package com.flipcash.app.myaccount.internal.blocklist + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.app.theme.FlipcashPreview +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the blocklist in each identity state to a PNG, so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class BlocklistScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersRowIdentityStates() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + val users = listOf( + BlockedUserProfile( + userId = listOf(1.toByte()), + displayName = "Grace Hopper", + handle = "@grace_hopper", + profilePicture = null, + blockedAt = at, + ), + // Blocking is reachable from a tip DM, so a blocked account need never have had a name. + BlockedUserProfile( + userId = listOf(2.toByte()), + displayName = "", + handle = "@sally_streamer", + profilePicture = null, + blockedAt = at, + ), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + val items = flowOf(PagingData.from(users)).collectAsLazyPagingItems() + FlipcashPreview(showBackground = true) { + Box(modifier = Modifier.width(360.dp).height(200.dp)) { + BlocklistScreenContent( + blocked = items, + unblocking = emptyMap(), + onUnblock = {}, + ) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("blocklist_row_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index 8fbc71b423..a44d473254 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt new file mode 100644 index 0000000000..ed1c519a89 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt @@ -0,0 +1,115 @@ +package com.flipcash.app.tipping.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the tips list row in each identity state to a PNG, so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class TipChatRowScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersRowIdentityStates() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + val rows = listOf( + // Name and handle both present: the row is one line, so only the name shows. + ConversationReference( + chatId = ChatId(byteArrayOf(1)), + displayName = "Grace Hopper", + handle = "@grace_hopper", + lastMessagePreview = "Sent you $5.00 in Dollars", + lastActivity = at, + ), + // No name — the handle stands in for it, on the same line. + ConversationReference( + chatId = ChatId(byteArrayOf(2)), + displayName = null, + handle = "@sally_streamer", + lastMessagePreview = "Sent you $1.00 in Dollars", + lastActivity = at, + unreadCount = 2, + ), + // Neither, which is what every name-less tipper used to render as. + ConversationReference( + chatId = ChatId(byteArrayOf(3)), + displayName = null, + handle = null, + lastMessagePreview = "Sent you $0.25 in Dollars", + lastActivity = at, + ), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column(modifier = Modifier.width(360.dp).padding(vertical = 8.dp)) { + rows.forEach { TipChatRow(chat = it, onClick = {}) } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("tip_chat_row_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +}