diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 8123d0f51..40e8047a9 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -230,6 +230,7 @@ dependencies { implementation(project(":apps:flipcash:features:messenger")) implementation(project(":apps:flipcash:features:invite")) implementation(project(":apps:flipcash:features:discovery")) + implementation(project(":apps:flipcash:features:user-profile")) implementation(project(":apps:flipcash:features:userflags")) implementation(project(":libs:crypto:solana")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index cbc4ff514..ba138c98d 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -50,6 +50,7 @@ import com.flipcash.app.tokens.TokenSelectScreen import com.flipcash.app.transactions.TransactionHistoryScreen import com.flipcash.app.userflags.UserFlagsScreen +import com.flipcash.app.userprofile.UpdateUserProfileFlowScreen import com.flipcash.app.withdrawal.WithdrawalFlowScreen import com.getcode.navigation.AppNavHost import com.getcode.navigation.NonDismissableRoute @@ -122,6 +123,12 @@ fun appEntryProvider( VerificationFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + // User Profile Management + annotatedEntry { key -> + UpdateUserProfileFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } + + // Menu annotatedEntry { AppSettingsScreen() } annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 2fac50864..3ca213b4f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -3,31 +3,33 @@ package com.flipcash.app.core import android.os.Parcelable import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep +import com.flipcash.app.core.onboarding.OnboardingStep import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep +import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.tokens.SwapStep import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.core.ui.flow.SteppedFlowRoute +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.core.verification.VerificationStep import com.flipcash.app.core.withdrawal.WithdrawalResult import com.flipcash.app.core.withdrawal.WithdrawalStep -import com.flipcash.app.core.chat.ChatStep -import com.flipcash.app.core.onboarding.OnboardingStep -import com.flipcash.app.core.tokens.FundingSource -import com.flipcash.app.core.ui.flow.SteppedFlowRoute import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult import com.getcode.navigation.flow.FlowStep -import kotlin.reflect.KClass import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.ui.core.RestrictionType import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import kotlin.reflect.KClass @Serializable @Parcelize @@ -138,6 +140,18 @@ sealed interface AppRoute : NavKey, Parcelable { ) } + @Serializable + @Parcelize + data class UpdateUserProfile( + val origin: AppRoute, + val includeName: Boolean = true, + val includePhoto: Boolean = true, + val target: AppRoute? = null, + ): AppRoute, FlowRouteWithResult { + override val initialStack: List + get() = buildUpdateUserProfileStack(includeName, includePhoto) + } + @Serializable @Parcelize sealed interface Sheets : AppRoute { @@ -155,6 +169,7 @@ sealed interface AppRoute : NavKey, Parcelable { */ @Serializable data class Send(val resumed: Boolean = false): Sheets + @Serializable data object Wallet : Sheets @Serializable @@ -300,3 +315,13 @@ private fun buildVerificationInitialStack( } return emptyList() } + +// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name first, then +// photo. In edit mode only the requested step(s) are included. +private fun buildUpdateUserProfileStack( + includeName: Boolean, + includePhoto: Boolean, +): List = buildList { + if (includeName) add(UpdateProfileStep.Name) + if (includePhoto) add(UpdateProfileStep.Photo) +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 22b2faa5c..ea0ff0d85 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -59,7 +59,7 @@ import com.getcode.ui.utils.heightOrZero import com.getcode.ui.utils.widthOrZero data class NavigationBarState( - val notificationUnreadCount: Int = 0, + val contactDmUnreadCount: Int = 0, val showToast: Boolean = false, val toastText: String? = null, val isPaused: Boolean = false, @@ -144,7 +144,7 @@ fun NavigationBar( NavBarButton.Send -> BottomBarAction( modifier = buttonModifier, label = stringResource(R.string.action_send), - badgeCount = state.notificationUnreadCount, + badgeCount = state.contactDmUnreadCount, painter = painterResource(R.drawable.ic_send_outlined), onClick = { onButtonClick(NavBarButton.Send) } ) @@ -301,7 +301,7 @@ private fun BottomBarAction( @Composable private fun NavigationBarPreview() { NavigationBar( - state = NavigationBarState(notificationUnreadCount = 100), + state = NavigationBarState(contactDmUnreadCount = 100), ) } @Preview diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileResult.kt new file mode 100644 index 000000000..ecbd0ce6c --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileResult.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.core.userprofile + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Result returned by the UpdateUserProfile flow to its caller. + */ + +@Serializable +sealed interface UpdateProfileResult : Parcelable { + @Parcelize + @Serializable + data object Success : UpdateProfileResult + + @Parcelize + @Serializable + data object Canceled : UpdateProfileResult +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt new file mode 100644 index 000000000..08661aba6 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.core.userprofile + +import android.os.Parcelable +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +sealed interface UpdateProfileStep : FlowStep, Parcelable { + @Parcelize + @Serializable + object Name : UpdateProfileStep + + @Parcelize + @Serializable + object Photo : UpdateProfileStep + + +} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 60e432d6e..3744cf07d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -854,4 +854,13 @@ Amount to buy Exchange fee + + What is your name? + Your Name + This Name is Not Allowed + Try a different name + Upload Your Photo + CThis photo will be shown when receiving tips + Your Name + 500x500 Recommended \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt index a7ab694fc..2129ce76e 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -109,7 +110,7 @@ internal class ContactListBuilder @Inject constructor( contact = contact, isOnFlipcash = true, lastActivity = summary.metadata.lastActivity, - conversation = Conversation( + conversation = ConversationReference( chatId = chatId, lastMessagePreview = formatPreview(summary, selfId, tokensByMint), unreadCount = summary.unreadCount, diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt index 388457f8e..8ecfcbd95 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt @@ -2,6 +2,7 @@ package com.flipcash.app.directsend.internal import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference import kotlin.time.Instant internal sealed interface ContactListItem { @@ -22,14 +23,6 @@ internal sealed interface ContactListItem { val contact: DeviceContact, val isOnFlipcash: Boolean, val lastActivity: Instant? = null, - val conversation: Conversation? = null, + val conversation: ConversationReference? = null, ) : ContactListItem } - -/** Presentation state derived from an existing DM with a contact. */ -internal data class Conversation( - val chatId: ChatId, - val lastMessagePreview: String? = null, - val unreadCount: Int = 0, - val isTyping: Boolean = false, -) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 264178324..2928b94bf 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.permissions.PickedContact import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.directsend.R +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.getcode.manager.BottomBarManager @@ -92,7 +93,7 @@ internal class SendFlowViewModel @Inject constructor( featureFlags.observe(FeatureFlag.PhoneNumberSend), featureFlags.observe(FeatureFlag.ContactPickerMode), contactCoordinator.state, - chatCoordinator.feed, + chatCoordinator.feed(ChatType.CONTACT_DM), ) { userState, phoneNumberSendFlag, contactPickerMode, contactState, chats -> val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null val phoneNumberSendEnabled = phoneNumberSendFlag || @@ -119,7 +120,7 @@ internal class SendFlowViewModel @Inject constructor( .map { it.searchState } .distinctUntilChanged() .flatMapLatest { snapshotFlow { it.text } }, - chatCoordinator.feed, + chatCoordinator.feed(ChatType.CONTACT_DM), tokenCoordinator.tokens, ) { contactState, searchText, chatFeed, tokens -> contactListBuilder.build( diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt index 17fb3bb9c..747ca65d3 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search @@ -36,39 +36,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.flipcash.app.contacts.ui.ContactAvatar -import com.flipcash.app.core.android.extensions.launchAppSettings import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.directsend.internal.ContactListItem -import com.flipcash.app.directsend.internal.Conversation import com.flipcash.app.permissions.ContactAccessHandle import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.directsend.R import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview +import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.theme.extraLarge import com.getcode.theme.extraSmall import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.CodeButton import com.getcode.util.formatLocalized -import com.getcode.util.permissions.PermissionResult import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -576,7 +570,7 @@ private fun ContactListPreview() { contact = flipcashContacts[0].contact, isOnFlipcash = true, lastActivity = now - 5.minutes, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(1)), lastMessagePreview = "Sent you $10.00", unreadCount = 2, @@ -587,7 +581,7 @@ private fun ContactListPreview() { contact = flipcashContacts[1].contact, isOnFlipcash = true, lastActivity = now - 1.hours, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(2)), lastMessagePreview = "See you soon", isTyping = true, @@ -598,7 +592,7 @@ private fun ContactListPreview() { contact = flipcashContacts[2].contact, isOnFlipcash = true, lastActivity = now - 26.hours, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(3)), lastMessagePreview = "You: Thanks!", ), diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index 0ba098cd0..c4dae753c 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -43,6 +43,34 @@ fun UserProfileScreen() { UserProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.UserProfile, + includeName = true, + includePhoto = false, + ) + ) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.UserProfile, + includeName = false, + includePhoto = true, + ) + ) + }.launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt index 220d6fb0f..53db5a82a 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -41,8 +42,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight.Companion.W600 import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.core.R import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition import com.getcode.theme.CodeTheme import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow @@ -58,6 +62,16 @@ internal fun UserProfileScreenContent( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = inset), ) { + // Profile header — avatar (tap to edit photo) + name with a pencil (tap to edit name). + item(contentType = "profile_header") { + ProfileHeader( + displayName = state.displayName, + profilePicture = state.profilePicture, + onEditName = { dispatch(UserProfileViewModel.Event.EditNameClicked) }, + onEditPhoto = { dispatch(UserProfileViewModel.Event.EditPhotoClicked) }, + ) + } + // Account Info section val hasAccountInfo = !state.publicKey.isNullOrEmpty() || !state.accountId.isNullOrEmpty() || @@ -139,27 +153,6 @@ internal fun UserProfileScreenContent( } } - // Display Name section - item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionDisplayName)) } - item(contentType = "profile_value") { - val name = state.displayName - if (!name.isNullOrEmpty()) { - CardRow { ProfileValueRow(value = name) } - } else { - CardRow { - Text( - text = stringResource(R.string.subtitle_noDisplayName), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, - ), - ) - } - } - } - // Phone section item(contentType = "section_header") { SectionHeader(stringResource(R.string.title_sectionPhone)) } item(contentType = "contact_method") { @@ -271,6 +264,58 @@ internal fun UserProfileScreenContent( } } +@Composable +private fun ProfileHeader( + displayName: String?, + profilePicture: MediaItem?, + onEditName: () -> Unit, + onEditPhoto: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x6), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + photoUri = profilePicture.displayUrl(), + displayName = displayName.orEmpty(), + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .clickable { onEditPhoto() }, + ) + Row( + modifier = Modifier.clickable { onEditName() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = displayName?.takeIf { it.isNotEmpty() } + ?: stringResource(R.string.subtitle_noDisplayName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(16.dp), + ) + } + } +} + +// Prefer the DISPLAY rendition (falling back to THUMBNAIL / any) for the avatar image URL. +private fun MediaItem?.displayUrl(): String? { + val renditions = this?.renditions ?: return null + return (renditions.firstOrNull { it.role == MediaItemRendition.Role.DISPLAY } + ?: renditions.firstOrNull { it.role == MediaItemRendition.Role.THUMBNAIL } + ?: renditions.firstOrNull()) + ?.blob?.downloadUrl +} + /** Non-swipeable card wrapper — just visual styling. */ @Composable private fun CardRow( @@ -292,19 +337,6 @@ private fun Modifier.cardStyle(shape: Shape = CodeTheme.shapes.medium): Modifier .fillMaxWidth() } -@Composable -private fun ProfileValueRow(value: String) { - Text( - text = value, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, - ), - ) -} - @Composable private fun ContactMethodRow( value: String, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt index 340964cc0..2087782ec 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt @@ -11,6 +11,7 @@ import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.ContactMethod import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -45,6 +46,7 @@ internal class UserProfileViewModel @Inject constructor( ) { internal data class State( val displayName: String? = null, + val profilePicture: MediaItem? = null, val phone: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, val phoneLinkedForPayment: Boolean = false, @@ -57,12 +59,19 @@ internal class UserProfileViewModel @Inject constructor( internal sealed interface Event { data class OnProfileUpdated( val displayName: String?, + val profilePicture: MediaItem?, val phone: VerifiableContactMethod?, val email: VerifiableContactMethod?, val linkedForPayment: Boolean, val socialAccounts: List, ) : Event + /** Open the update-profile flow to set/replace the display name. */ + data object EditNameClicked : Event + + /** Open the update-profile flow to set/replace the profile picture. */ + data object EditPhotoClicked : Event + data object UnlinkPhoneClicked : Event data object UnlinkEmailClicked : Event data object ConnectPhoneClicked : Event @@ -94,6 +103,7 @@ internal class UserProfileViewModel @Inject constructor( dispatchEvent( Event.OnProfileUpdated( displayName = profile?.displayName, + profilePicture = profile?.profilePicture, // Carry the contact (value + verified) so unverified entries still show. phone = profile?.phoneNumber, email = profile?.email, @@ -272,6 +282,7 @@ internal class UserProfileViewModel @Inject constructor( is Event.OnProfileUpdated -> { state -> state.copy( displayName = event.displayName, + profilePicture = event.profilePicture, phone = event.phone, email = event.email, phoneLinkedForPayment = event.linkedForPayment, @@ -293,6 +304,8 @@ internal class UserProfileViewModel @Inject constructor( Event.ConnectEmailClicked, Event.ReplacePhoneClicked, Event.ReplaceEmailClicked, + Event.EditNameClicked, + Event.EditPhotoClicked, is Event.UnlinkSocialAccountClicked, Event.NavigateToPhoneVerification, Event.NavigateToEmailVerification, diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt index 41e7232be..9624eabb6 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -39,6 +39,7 @@ class ContactMethodsViewModelStateTest { val updated = reduce( UserProfileViewModel.Event.OnProfileUpdated( displayName = "Alice", + profilePicture = null, phone = VerifiableContactMethod("+15551234567", verified = true), email = VerifiableContactMethod("test@example.com", verified = false), linkedForPayment = true, @@ -58,6 +59,7 @@ class ContactMethodsViewModelStateTest { val updated = reduce( UserProfileViewModel.Event.OnProfileUpdated( displayName = null, + profilePicture = null, phone = null, email = null, linkedForPayment = false, diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt index 0fa687779..a84ca8972 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt @@ -40,7 +40,7 @@ internal fun ScannerNavigationBar( modifier = modifier, config = effectiveConfig, state = NavigationBarState( - notificationUnreadCount = state.notificationUnreadCount, + contactDmUnreadCount = state.contactDmUnreadCount, showToast = billState.showToast && billState.toast != null, toastText = billState.toast?.formattedAmount, isPaused = isPaused, diff --git a/apps/flipcash/features/user-profile/.gitignore b/apps/flipcash/features/user-profile/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/apps/flipcash/features/user-profile/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts new file mode 100644 index 000000000..cd2e26821 --- /dev/null +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.features.userprofile" +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) + testImplementation(libs.mockito.kotlin) + + implementation(libs.bundles.kotlinx.serialization) + + implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:blob")) + implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:userflags")) + + implementation(project(":libs:messaging")) +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt new file mode 100644 index 000000000..8e63137fe --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.userprofile + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.userprofile.internal.name.NameEntryScreen +import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen +import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.FlowExitReason +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.deliverFlowResult +import com.getcode.navigation.flow.rememberInitialStack +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.NavResultStateRegistry + +@Composable +fun UpdateUserProfileFlowScreen( + route: AppRoute.UpdateUserProfile, + resultStateRegistry: NavResultStateRegistry, +) { + // Capture the outer navigator before FlowHost overrides LocalCodeNavigator. + val outerNavigator = LocalCodeNavigator.current + + val steps = route.rememberInitialStack() + + FlowHost( + steps = steps, + resumeAt = 0, + resultStateRegistry = resultStateRegistry, + // Walking off the end of the step list (the last proceed()) completes the flow. + completedResult = UpdateProfileResult.Success, + onExit = { reason, _ -> + val result: UpdateProfileResult = when (reason) { + is FlowExitReason.Completed -> reason.result + FlowExitReason.Canceled, + FlowExitReason.BackedOutOfRoot -> UpdateProfileResult.Canceled + } + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) + if (route.target != null && result is UpdateProfileResult.Success) { + outerNavigator.replaceAll(route.target!!) + } else { + outerNavigator.pop() + } + }, + entryProvider = profileUpdateProvider(route), + ) +} + +private fun profileUpdateProvider( + route: AppRoute.UpdateUserProfile, +): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { + NameEntryScreen() + } + annotatedEntry { + PhotoSelectionScreen() + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt new file mode 100644 index 000000000..fa6b0cecd --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt @@ -0,0 +1,139 @@ +package com.flipcash.app.userprofile.internal.name + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.core.ui.transitions.SharedTransition +import com.flipcash.app.core.ui.transitions.sharedElementTransition +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun NameEntryScreen() { + val flowNavigator = rememberFlowNavigator() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val keyboard = rememberKeyboardController() + + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = { + keyboard.hideIfVisible { + flowNavigator.back() + } + }, + ) + NameEntryScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} + +@Composable +private fun NameEntryScreenContent( + state: NameEntryViewModel.State, + dispatchEvent: (NameEntryViewModel.Event) -> Unit, +) { + val keyboard = rememberKeyboardController() + CodeScaffold( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset + ), + text = stringResource(R.string.title_profileNameSelection), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3 + ).imePadding(), + text = stringResource(R.string.action_next), + enabled = state.hasName && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + dispatchEvent(NameEntryViewModel.Event.CheckName) + } + }, + ) + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + DisplayTextInput( + state = state.nameFieldState, + placeholder = stringResource(R.string.hint_profileName), + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .focusRequester(focusRequester), + textModifier = Modifier.sharedElementTransition( + transition = SharedTransition.CurrencyName, + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + dispatchEvent(NameEntryViewModel.Event.CheckName) + } + }, + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt new file mode 100644 index 000000000..665cb2c3c --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -0,0 +1,133 @@ +package com.flipcash.app.userprofile.internal.name + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.extensions.flatMapResult +import com.flipcash.app.core.extensions.onResult +import com.flipcash.features.userprofile.R +import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.TextModerationError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class NameEntryViewModel @Inject constructor( + userManager: UserManager, + private val moderationController: ModerationController, + private val profileController: ProfileController, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val nameFieldState: TextFieldState = TextFieldState(), + val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + val hasName: Boolean + get() = nameFieldState.text.isNotBlank() + } + + sealed interface Event { + data object CheckName : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false + ) : Event + + data object OnNameApproved : Event + } + + init { + userManager.state + .mapNotNull { it.userProfile } + .map { profile -> profile.displayName } + .onEach { name -> + val inputState = stateFlow.value.nameFieldState + inputState.setTextAndPlaceCursorAtEnd(name.orEmpty()) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { stateFlow.value.nameFieldState.text.toString() } + .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } + .map { moderationController.moderateText(it.trim()) } + .flatMapResult { result -> + when (result.flaggedCategory) { + ModerationResult.FlaggedCategory.NONE -> { + Result.success(result.attestation) + } + + else -> Result.failure(TextModerationError.Flagged(result.flaggedCategory)) + } + }.flatMapResult { + profileController.setDisplayName(stateFlow.value.nameFieldState.text.toString()) + }.onResult( + onSuccess = { + viewModelScope.launch { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnNameApproved) + dispatchEvent(Event.UpdateProcessingState()) + } + }, + onError = { cause -> + dispatchEvent(Event.UpdateProcessingState()) + when (cause) { + is ValidationException, + is TextModerationError.Flagged, + is TextModerationError.Denied -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowed) + ) + } + + else -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_nameCheckFailed), + message = resources.getString(R.string.error_description_nameCheckFailed), + ) + } + } + } + ).launchIn(viewModelScope) + } + + companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + Event.CheckName -> { state -> state } + is Event.UpdateProcessingState -> { state -> + val current = state.processingState + state.copy( + processingState = current.copy( + loading = event.loading, + success = event.success + ) + ) + } + + Event.OnNameApproved -> { state -> state } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt new file mode 100644 index 000000000..b0e534571 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt @@ -0,0 +1,221 @@ +package com.flipcash.app.userprofile.internal.photo + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.core.data.isLoading +import com.flipcash.app.core.ui.transitions.SharedTransition +import com.flipcash.app.core.ui.transitions.sharedBoundsTransition +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White50 +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun PhotoSelectionScreen() { + val flowNavigator = rememberFlowNavigator() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val keyboard = rememberKeyboardController() + + Column { + AppBarWithTitle( + backButton = true, + onBackIconClicked = { + keyboard.hideIfVisible { + flowNavigator.back() + } + }, + ) + PhotoSelectionScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} + +@Composable +private fun PhotoSelectionScreenContent( + state: PhotoSelectionViewModel.State, + dispatchEvent: (PhotoSelectionViewModel.Event) -> Unit, +) { + val pickMedia = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + if (uri != null) { + trace(tag = "UserProfile", message = "image selected @ $uri", type = TraceType.User) + dispatchEvent(PhotoSelectionViewModel.Event.OnImageSelected(uri)) + } else { + trace(tag = "UserProfile", message = "No image selected", type = TraceType.User) + } + } + + CodeScaffold( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Column( + modifier = Modifier.fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x8), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Text( + text = stringResource(R.string.title_profileImageSelection), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.subtitle_profileImageSelection), + style = CodeTheme.typography.textSmall, + textAlign = TextAlign.Center, + color = CodeTheme.colors.textSecondary, + ) + } + }, + bottomBar = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_next), + enabled = state.image.isLoaded() && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + }, + ) + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + Box( + modifier = Modifier + .size(150.dp) + .sharedBoundsTransition( + transition = SharedTransition.CurrencyIcon, + ) + .background( + color = CodeTheme.colors.divider, + shape = CircleShape, + ).clip(CircleShape) + .clickable { + pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + contentAlignment = Alignment.Center, + ) { + Crossfade(targetState = state.image) { icon -> + when { + icon.isLoaded() -> { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(icon.data) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + onError = { + it.result.throwable.printStackTrace() + }, + ) + } + icon.isLoading() && icon.dataOrNull != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = White50, + modifier = Modifier.size(48.dp), + ) + } + } + } + } + } + + + Text( + modifier = Modifier.sharedBoundsTransition( + transition = SharedTransition.CurrencyName, + ), + text = state.name.ifBlank { stringResource(R.string.placeholder_profileDisplayName) }, + style = CodeTheme.typography.displaySmall, + color = Color.White, + ) + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt new file mode 100644 index 000000000..a793ddee9 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt @@ -0,0 +1,197 @@ +package com.flipcash.app.userprofile.internal.photo + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.extensions.flatMapResult +import com.flipcash.app.core.extensions.onResult +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.features.userprofile.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.ImageModerationError +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.TextModerationError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.uploadMimeFor +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class PhotoSelectionViewModel @Inject constructor( + dispatchers: DispatcherProvider, + userManager: UserManager, + private val moderationController: ModerationController, + private val profileController: ProfileController, + private val blobStorage: BlobStorageCoordinator, + private val resources: ResourceHelper, + val contentReader: ContentReader, +) : BaseViewModel( + initialState = State(name = userManager.profile?.displayName.orEmpty()), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val name: String, + val image: Loadable = Loadable.Loading(), + val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty, + val processingState: LoadingSuccessState = LoadingSuccessState(), + // Server upload constraints (accepted MIME types + size ceilings), used to filter selection. + val uploadPolicy: UploadPolicy? = null, + // MIME type of the re-encoded image bytes to upload; resolved from the selected image. + val imageMimeType: String = uploadMimeFor(null), + ) + + sealed interface Event { + data object CheckImage : Event + data class UploadPolicyLoaded(val policy: UploadPolicy) : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false + ) : Event + + data object OnImageApproved : Event + data class OnImageSelected(val image: Uri) : Event + data class OnImageCached(val image: Uri, val mimeType: String) : Event + data object OnImageCleared : Event + } + + init { + // Observe the policy — the coordinator serves the launch-preloaded cache and self-refreshes + // it if it has aged past its ttl, re-emitting the fresh value here. + blobStorage.policy + .filterNotNull() + .onEach { dispatchEvent(Event.UploadPolicyLoaded(it)) } + .launchIn(viewModelScope) + + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + val sourceMime = contentReader.mimeType(event.image) + val cached = contentReader.copyToCache( + uri = event.image, + fileName = "user_profile_${System.nanoTime()}", + maxSize = 500, + mimeType = sourceMime, + ) ?: return@mapNotNull null + // The cache re-encodes (stripping EXIF); declare the type those bytes actually are. + cached to uploadMimeFor(sourceMime) + } + .flowOn(dispatchers.IO) + .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.image.dataOrNull } + .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } + .map { moderationController.moderateImage(it) } + .flatMapResult { result -> + when (result.flaggedCategory) { + ModerationResult.FlaggedCategory.NONE -> Result.success(result.attestation) + else -> Result.failure(ImageModerationError.Flagged(result.flaggedCategory)) + } + } + .flatMapResult { + // Moderation passed — upload the image bytes to storage in one coordinated + // call, then set the returned blob as the profile picture. + val uri = stateFlow.value.image.dataOrNull + ?: return@flatMapResult Result.failure(IllegalStateException("No image selected")) + val bytes = contentReader.readBytes(uri) + ?: return@flatMapResult Result.failure(IllegalStateException("Unable to read image")) + blobStorage.upload(bytes = bytes, mimeType = stateFlow.value.imageMimeType) + } + .flatMapResult { blobId -> + profileController.setProfilePicture(blobId) + } + .onResult( + onSuccess = { _ -> + viewModelScope.launch { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnImageApproved) + dispatchEvent(Event.UpdateProcessingState()) + } + }, + onError = { cause -> + dispatchEvent(Event.UpdateProcessingState()) + stateFlow.value.image.dataOrNull?.let { contentReader.removeFromCache(it) } + dispatchEvent(Event.OnImageCleared) + when (cause) { + is ValidationException, + is ImageModerationError.Flagged, + is ImageModerationError.Denied -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed) + ) + } + + is ImageModerationError.UnsupportedFormat -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotSupported), + message = resources.getString(R.string.error_description_imageNotSupported) + ) + } + + else -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + } + } + ) + .launchIn(viewModelScope) + } + + companion object { + + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + Event.CheckImage -> { state -> state } + is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) } + is Event.UpdateProcessingState -> { state -> + val current = state.processingState + state.copy( + processingState = current.copy( + loading = event.loading, + success = event.success + ) + ) + } + + Event.OnImageApproved -> { state -> state } + is Event.OnImageCached -> { state -> + state.copy(image = Loadable.Loaded(event.image), imageMimeType = event.mimeType) + } + Event.OnImageCleared -> { state -> + state.copy(image = Loadable.Loading()) + } + is Event.OnImageSelected -> { state -> + state.copy(image = Loadable.Loading(event.image)) + } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/blob/build.gradle.kts b/apps/flipcash/shared/blob/build.gradle.kts new file mode 100644 index 000000000..2df4e0416 --- /dev/null +++ b/apps/flipcash/shared/blob/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.blob" +} + +dependencies { + implementation(libs.bundles.hilt) + implementation(libs.androidx.datastore) + implementation(libs.bundles.kotlinx.serialization) + + implementation(project(":libs:coroutines")) + implementation(project(":services:flipcash")) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) +} diff --git a/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/BlobStorageCoordinator.kt b/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/BlobStorageCoordinator.kt new file mode 100644 index 000000000..dd35938d2 --- /dev/null +++ b/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/BlobStorageCoordinator.kt @@ -0,0 +1,161 @@ +package com.flipcash.app.blob + +import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.BlobStorageController +import com.flipcash.services.models.InitiateExternalUploadError +import com.flipcash.services.models.blob.MimeTypeConstraints +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.chat.BlobId +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * App-layer facade over blob storage ([BlobStorageController]). ViewModels use this coordinator + * rather than the service-layer controller, mirroring the app's Coordinator → Controller pattern + * (e.g. [ChatCoordinator][com.flipcash.shared.chat.ChatCoordinator]). + * + * It also owns the persisted [UploadPolicy] cache, which honours the policy's own `ttl` and + * `version`: [preloadPolicy] is called on launch by the session controller, [policy] re-fetches + * when the cached copy has aged past its ttl, and [upload] re-fetches when the server rejects an + * upload on policy grounds (a version-mismatch signal). + */ +@Singleton +class BlobStorageCoordinator @Inject constructor( + @param:ApplicationContext private val context: Context, + private val blobStorageController: BlobStorageController, + dispatchers: DispatcherProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) + + private val dataStore = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + scope = scope, + produceFile = { context.preferencesDataStoreFile("upload-policy") } + ) + + private val json = Json { ignoreUnknownKeys = true } + + private val refreshing = AtomicBoolean(false) + + /** + * The upload policy, kept fresh. Emits the cached value and, whenever that value is stale (past + * its `ttl`) or absent, triggers a background refresh whose result re-emits here. Observe it + * directly from a ViewModel; a stale cache resolves to a fresh value without a manual fetch. + */ + val policy: Flow = dataStore.data + .map { it.decode() } + .onEach { cached -> if (cached == null || !cached.isFresh()) triggerRefresh() } + .map { it?.toDomain() } + + /** Fetches the latest upload policy and caches it (stamped with the fetch time). */ + suspend fun preloadPolicy(): Result = + blobStorageController.getUploadPolicy().onSuccess { persist(it) } + + /** + * Uploads [bytes] to storage and returns the READY [BlobId] — reserve, PUT/POST, complete, and + * poll are all handled inside the controller. A policy-driven rejection invalidates the cached + * policy (the server echoes a newer policy version on such denials). + */ + suspend fun upload(bytes: ByteArray, mimeType: String): Result { + val result = blobStorageController.upload(bytes, mimeType) + result.exceptionOrNull()?.let { refreshIfPolicyChanged(it) } + return result + } + + suspend fun reset() { + dataStore.edit { it.remove(KEY_UPLOAD_POLICY) } + } + + // Fire-and-forget refresh, deduped so a burst of stale emissions launches at most one fetch. + private fun triggerRefresh() { + if (refreshing.compareAndSet(false, true)) { + scope.launch { + try { + preloadPolicy() + } finally { + refreshing.set(false) + } + } + } + } + + // A policy-driven denial means our cached policy let something through the server now rejects. + // Re-fetch when the echoed version differs from what we cached (or we have nothing cached). + private suspend fun refreshIfPolicyChanged(cause: Throwable) { + val deniedVersion = when (cause) { + is InitiateExternalUploadError.UnsupportedType -> cause.policyVersion + is InitiateExternalUploadError.TooLarge -> cause.policyVersion + else -> return + } + if (deniedVersion == null || deniedVersion != cached()?.version) { + preloadPolicy() + } + } + + private suspend fun persist(policy: UploadPolicy) { + val raw = json.encodeToString(CachedUploadPolicy.fromDomain(policy, now())) + dataStore.edit { it[KEY_UPLOAD_POLICY] = raw } + } + + private suspend fun cached(): CachedUploadPolicy? = dataStore.data.first().decode() + + private fun Preferences.decode(): CachedUploadPolicy? = + this[KEY_UPLOAD_POLICY]?.let { raw -> + runCatching { json.decodeFromString(raw) }.getOrNull() + } + + private fun CachedUploadPolicy.isFresh(): Boolean = now() - fetchedAtMillis < ttlMillis + + private fun now(): Long = System.currentTimeMillis() + + companion object { + private val KEY_UPLOAD_POLICY = stringPreferencesKey("cached_upload_policy") + } +} + +/** + * On-disk form. [UploadPolicy.ttl] is a [kotlin.time.Duration] (not kotlinx-serializable) so it is + * stored as milliseconds; [fetchedAtMillis] stamps when it was cached so freshness can be checked + * against the ttl; [MimeTypeConstraints] is already `@Serializable` and stored as-is. + */ +@kotlinx.serialization.Serializable +private data class CachedUploadPolicy( + val version: String, + val ttlMillis: Long, + val fetchedAtMillis: Long, + val mimeTypeConstraints: List, +) { + fun toDomain(): UploadPolicy = UploadPolicy( + version = version, + ttl = ttlMillis.milliseconds, + mimeTypeConstraints = mimeTypeConstraints, + ) + + companion object { + fun fromDomain(policy: UploadPolicy, fetchedAtMillis: Long): CachedUploadPolicy = CachedUploadPolicy( + version = policy.version, + ttlMillis = policy.ttl.inWholeMilliseconds, + fetchedAtMillis = fetchedAtMillis, + mimeTypeConstraints = policy.mimeTypeConstraints, + ) + } +} diff --git a/apps/flipcash/shared/blob/src/test/kotlin/com/flipcash/app/blob/BlobStorageCoordinatorTest.kt b/apps/flipcash/shared/blob/src/test/kotlin/com/flipcash/app/blob/BlobStorageCoordinatorTest.kt new file mode 100644 index 000000000..8d1810eb8 --- /dev/null +++ b/apps/flipcash/shared/blob/src/test/kotlin/com/flipcash/app/blob/BlobStorageCoordinatorTest.kt @@ -0,0 +1,102 @@ +package com.flipcash.app.blob + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.BlobStorageController +import com.flipcash.services.models.InitiateExternalUploadError +import com.flipcash.services.models.blob.MimeTypeConstraints +import com.flipcash.services.models.blob.UploadPolicy +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class BlobStorageCoordinatorTest { + + private val controller = mockk() + private val context = ApplicationProvider.getApplicationContext() + + private fun TestScope.newCoordinator(): BlobStorageCoordinator { + val test = UnconfinedTestDispatcher(testScheduler) + val dispatchers = object : DispatcherProvider { + override val Default: CoroutineDispatcher = test + override val Main: CoroutineDispatcher = test + override val IO: CoroutineDispatcher = test + } + return BlobStorageCoordinator(context, controller, dispatchers) + } + + private fun policy(version: String, ttl: Duration) = + UploadPolicy( + version = version, + ttl = ttl, + mimeTypeConstraints = listOf(MimeTypeConstraints("image/*", 1_000, null)), + ) + + @Test + fun `preloaded policy is served from the cache`() = runTest { + coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours)) + val coordinator = newCoordinator() + coordinator.reset() + + coordinator.preloadPolicy() + + assertEquals("v1", coordinator.policy.first()?.version) + } + + @Test + fun `a policy-denied upload with a new version refreshes the cached policy`() = runTest { + coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours)) + coEvery { controller.upload(any(), any()) } returns + Result.failure(InitiateExternalUploadError.UnsupportedType(policyVersion = "v2")) + val coordinator = newCoordinator() + coordinator.reset() + coordinator.preloadPolicy() // seeds v1 (getUploadPolicy #1) + + coordinator.upload(byteArrayOf(1), "image/png") + + // preload + a refresh, because the denied version (v2) differs from the cached one (v1). + coVerify(exactly = 2) { controller.getUploadPolicy() } + } + + @Test + fun `a policy-denied upload with the same version does not refresh`() = runTest { + coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours)) + coEvery { controller.upload(any(), any()) } returns + Result.failure(InitiateExternalUploadError.UnsupportedType(policyVersion = "v1")) + val coordinator = newCoordinator() + coordinator.reset() + coordinator.preloadPolicy() + + coordinator.upload(byteArrayOf(1), "image/png") + + coVerify(exactly = 1) { controller.getUploadPolicy() } + } + + @Test + fun `a non-policy upload failure does not refresh`() = runTest { + coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours)) + coEvery { controller.upload(any(), any()) } returns Result.failure(RuntimeException("network")) + val coordinator = newCoordinator() + coordinator.reset() + coordinator.preloadPolicy() + + coordinator.upload(byteArrayOf(1), "image/png") + + coVerify(exactly = 1) { controller.getUploadPolicy() } + } +} 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 new file mode 100644 index 000000000..e1ee984d7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -0,0 +1,11 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.models.chat.ChatId + +/** Presentation state derived from an existing DM with a contact. */ +data class ConversationReference( + val chatId: ChatId, + val lastMessagePreview: String? = null, + val unreadCount: Int = 0, + val isTyping: Boolean = false, +) \ No newline at end of file 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 index 0660397ef..dcbb35201 100644 --- 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 @@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact 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.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ReactionSummary @@ -21,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow * Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate]. */ interface FeedOperations { - /** Reactive list of all DM conversations, sorted by last activity. */ - val feed: Flow> + /** Reactive list of [chatType] conversations, sorted by last activity. */ + fun feed(chatType: ChatType): Flow> - /** Emits the number of conversations that have unread messages. */ - fun observeUnreadConversations(): Flow + /** Emits the number of [chatType] conversations that have unread messages. */ + fun observeUnreadConversations(chatType: ChatType): Flow /** Triggers a server-side feed sync. Safe to call redundantly. */ fun refreshFeed() diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 0e01828cf..47d806de4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -72,37 +72,44 @@ class FeedSyncDelegate @Inject constructor( // region FeedOperations - override val feed: Flow> - get() = stateHolder.state.map { state -> + override fun feed(chatType: ChatType): Flow> = + stateHolder.state.map { state -> val selfId = userManager.accountId val selfPhone = userManager.profile?.verifiedPhoneNumber val isSelf = { member: ChatMember -> member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone) } - state.feed.mapNotNull { metadata -> - val otherMember = metadata.members.firstOrNull { !isSelf(it) } - ?: return@mapNotNull null - val profile = otherMember.userProfile - val hasIdentity = !profile.displayName.isNullOrBlank() || - !profile.verifiedPhoneNumber.isNullOrBlank() - if (!hasIdentity) return@mapNotNull null - - val readPointer = metadata.members - .firstOrNull { it.userId == selfId } - ?.pointers - ?.firstOrNull { it.type == PointerType.READ } - ?.value ?: 0L - - val unreadCount = metadata.lastMessage?.let { lastMsg -> - if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 - } ?: 0 - - ChatSummary(metadata = metadata, unreadCount = unreadCount) - } + state.feed + .filter { it.type == chatType } + .mapNotNull { metadata -> + val otherMember = metadata.members.firstOrNull { !isSelf(it) } + ?: return@mapNotNull null + + // Contact DMs require a resolvable identity (phone / display name). Tip DMs are + // identified by user id and have no phone by design, so they are never dropped. + if (chatType == ChatType.CONTACT_DM) { + val profile = otherMember.userProfile + val hasIdentity = !profile.displayName.isNullOrBlank() || + !profile.verifiedPhoneNumber.isNullOrBlank() + if (!hasIdentity) return@mapNotNull null + } + + val readPointer = metadata.members + .firstOrNull { it.userId == selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value ?: 0L + + val unreadCount = metadata.lastMessage?.let { lastMsg -> + if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 + } ?: 0 + + ChatSummary(metadata = metadata, unreadCount = unreadCount) + } } - override fun observeUnreadConversations(): Flow { - return feed.map { summaries -> summaries.count { it.unreadCount > 0 } } + override fun observeUnreadConversations(chatType: ChatType): Flow { + return feed(chatType).map { summaries -> summaries.count { it.unreadCount > 0 } } } override fun refreshFeed() { @@ -155,13 +162,25 @@ class FeedSyncDelegate @Inject constructor( } } + /** + * Fetches the CONTACT_DM and TIP_DM feeds and returns their merged chats. The contact feed is + * required (its failure fails the whole sync, preserving prior behaviour); a TIP_DM failure is + * tolerated so tips never break the main DM list. Each chat carries its own [ChatType]. + */ + internal suspend fun fetchCombinedFeed(): Result> { + val contact = chatController.getDmChatFeed(ChatType.CONTACT_DM) + .getOrElse { return Result.failure(it) } + val tip = chatController.getDmChatFeed(ChatType.TIP_DM).getOrNull() + return Result.success(contact.chats + (tip?.chats ?: emptyList())) + } + private suspend fun performFeedSync() { stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) } - chatController.getDmChatFeed(ChatType.CONTACT_DM) - .onSuccess { page -> - metadataDataSource.upsert(page.chats) + fetchCombinedFeed() + .onSuccess { chats -> + metadataDataSource.upsert(chats) - for (chat in page.chats) { + for (chat in chats) { memberDataSource.upsert(chat.chatId, chat.members) chat.lastMessage?.let { msg -> messageDataSource.upsert(chat.chatId, listOf(msg)) @@ -169,9 +188,9 @@ class FeedSyncDelegate @Inject constructor( } stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) } - trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) + trace(tag = TAG, message = "Feed synced: ${chats.size} chats", type = TraceType.Process) - for (chat in page.chats) { + for (chat in chats) { if (chat.latestEventSequence > 0) { val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) if (localSeq > 0 && localSeq < chat.latestEventSequence) { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt new file mode 100644 index 000000000..1604b294a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt @@ -0,0 +1,64 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +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.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedSyncCombinedFeedTest { + + private fun metadata(hex: String, type: ChatType) = ChatMetadata( + chatId = ChatId(hex), + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + ) + + private fun delegateWith(chatController: ChatController) = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = mockk(relaxed = true), + memberDataSource = mockk(relaxed = true), + stateHolder = mockk(relaxed = true), + userManager = mockk(relaxed = true), + ) + + @Test + fun `fetches both contact and tip feeds and merges chats`() = runTest { + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false)) + coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("bb", ChatType.TIP_DM)), null, false)) + + val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow() + + assertEquals(2, merged.size) + coVerify { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } + coVerify { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } + } + + @Test + fun `contact feed still returned when tip feed fails`() = runTest { + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false)) + coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns + Result.failure(RuntimeException("tip feed unavailable")) + + val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow() + + assertEquals(1, merged.size) + assertEquals(ChatType.CONTACT_DM, merged.first().type) + } +} diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index 60533848e..58cd9629d 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation(testFixtures(project(":libs:coroutines"))) testImplementation(testFixtures(project(":ui:resources"))) + implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:activityfeed")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 669878ad9..747e5c214 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -59,7 +59,7 @@ data class SessionState( val billResult: BillDeterminationResult = BillDeterminationResult.None, val restrictionType: RestrictionType? = null, val isRemoteSendLoading: Boolean = false, - val notificationUnreadCount: Int = 0, + val contactDmUnreadCount: Int = 0, val tokens: List = emptyList(), val isPhoneNumberSendEnabled: Boolean = false, val addMoneyUx: Boolean = false, 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 839d50a10..474758471 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 @@ -6,6 +6,8 @@ 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.app.blob.BlobStorageCoordinator +import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.updater.ProfileUpdater @@ -99,6 +101,7 @@ class RealSessionController @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, + private val blobStorageCoordinator: BlobStorageCoordinator, networkObserver: NetworkConnectivityListener, featureFlagController: FeatureFlagController, appSettingsCoordinator: AppSettingsCoordinator, @@ -194,9 +197,18 @@ class RealSessionController @Inject constructor( .map { it.authState } .filter { it.isAtLeastRegistered } .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeUnreadConversations() } + .flatMapLatest { chatCoordinator.observeUnreadConversations(ChatType.CONTACT_DM) } .distinctUntilChanged() - .onEach { count -> stateHolder.update { it.copy(notificationUnreadCount = count) } } + .onEach { count -> stateHolder.update { it.copy(contactDmUnreadCount = count) } } + .launchIn(scope) + + // Preload the blob upload policy once registered so profile-photo selection can filter and + // validate against it without a network round-trip. Cached in the BlobStorageCoordinator. + userManager.state + .map { it.authState } + .filter { it.isAtLeastRegistered } + .distinctUntilChanged() + .onEach { blobStorageCoordinator.preloadPolicy() } .launchIn(scope) appSettingsCoordinator diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/BlobUploader.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/BlobUploader.kt new file mode 100644 index 000000000..5f52538d0 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/BlobUploader.kt @@ -0,0 +1,12 @@ +package com.flipcash.services + +import com.flipcash.services.models.blob.UploadTarget + +/** + * Uploads raw bytes directly to object storage using a presigned [UploadTarget] minted by + * `BlobStorage.initiateExternalUpload`. The gRPC layer never carries the bytes — this is a plain + * HTTP PUT (raw body) or POST (multipart/form-data) straight to the storage provider. + */ +interface BlobUploader { + suspend fun upload(bytes: ByteArray, mimeType: String, target: UploadTarget): Result +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt new file mode 100644 index 000000000..8ee55f36a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt @@ -0,0 +1,85 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.BlobUploader +import com.flipcash.services.models.BlobNotReadyException +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobStatus +import com.flipcash.services.repository.BlobStorageRepository +import com.flipcash.services.user.UserManager +import com.getcode.ed25519.Ed25519 +import kotlinx.coroutines.delay +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Client for blob-storage uploads. [upload] performs the entire direct-to-storage handshake behind a + * single call — reserve, PUT/POST the bytes, advise completion, and poll until the blob is READY — + * returning the durable [BlobId] to hand to e.g. `ProfileController.setProfilePicture`. Callers use + * [getUploadPolicy] up front to filter selection and validate size before uploading. + */ +class BlobStorageController @Inject constructor( + private val repository: BlobStorageRepository, + private val uploader: BlobUploader, + private val userManager: UserManager, +) { + + private companion object { + val POLL_INTERVAL = 500.milliseconds + val POLL_TIMEOUT = 30.seconds + } + + /** The server's current upload constraints (accepted MIME types + size ceilings). */ + suspend fun getUploadPolicy(): Result { + val owner = owner() ?: return noAccount() + return repository.getUploadPolicy(owner) + } + + /** + * Uploads [bytes] to storage and returns the READY [BlobId]. Reserves a presigned target, + * PUTs/POSTs the bytes directly to storage, signals completion, and polls until the server + * finishes validating/transcoding — so callers never orchestrate the individual steps. + */ + suspend fun upload(bytes: ByteArray, mimeType: String): Result { + val owner = owner() ?: return noAccount() + + val reservation = repository.initiateExternalUpload(mimeType, bytes.size.toLong(), owner) + .getOrElse { return Result.failure(it) } + + uploader.upload(bytes, mimeType, reservation.target) + .getOrElse { return Result.failure(it) } + + // Advisory — the storage-completion event finalizes the blob even if this is skipped/fails. + repository.completeExternalUpload(reservation.blobId, owner) + + return awaitReady(reservation.blobId, owner) + } + + private suspend fun awaitReady(blobId: BlobId, owner: Ed25519.KeyPair): Result { + var elapsed: Duration = Duration.ZERO + while (elapsed < POLL_TIMEOUT) { + // A single-id query resolves to at most one blob, so first() is the one we asked for. + val blob = repository.getBlobs(listOf(blobId), owner) + .getOrElse { return Result.failure(it) } + .firstOrNull() + + when (blob?.status) { + BlobStatus.READY -> return Result.success(blobId) + BlobStatus.REJECTED -> return Result.failure(BlobRejectedException(blob.rejection)) + else -> { + delay(POLL_INTERVAL) + elapsed += POLL_INTERVAL + } + } + } + return Result.failure(BlobNotReadyException()) + } + + private fun owner(): Ed25519.KeyPair? = userManager.accountCluster?.authority?.keyPair + + private fun noAccount(): Result = + Result.failure(Throwable("No account cluster in UserManager")) +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 280259753..051d6763b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -84,6 +84,9 @@ class ProfileController @Inject constructor( ?: return Result.failure(Throwable("No account cluster in UserManager")) return repository.setDisplayName(displayName, owner) + // Reflect the change locally so anything observing the profile (e.g. a setup flow + // deciding which steps remain) sees it without waiting for a refresh. + .onSuccess { mergeLocalProfile { it.copy(displayName = displayName) } } } /** @@ -97,6 +100,14 @@ class ProfileController @Inject constructor( ?: return Result.failure(Throwable("No account cluster in UserManager")) return repository.setProfilePicture(blobId, owner) + .onSuccess { media -> mergeLocalProfile { it.copy(profilePicture = media) } } + } + + // Applies [transform] to the locally cached profile (or a minimal one if none is cached yet) + // and publishes it through UserManager. + private fun mergeLocalProfile(transform: (UserProfile) -> UserProfile) { + val base = userManager.profile ?: UserProfile.Empty + userManager.set(transform(base)) } suspend fun linkTwitterXAccount( 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 015d94ec0..b202f2ba4 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 @@ -12,8 +12,11 @@ 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.BlobUploader +import com.flipcash.services.internal.network.HttpBlobUploader import com.flipcash.services.internal.network.services.AccountService import com.flipcash.services.internal.network.services.ActivityFeedService +import com.flipcash.services.internal.network.services.BlobStorageService import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.internal.network.services.EventStreamingService import com.flipcash.services.internal.network.services.ChatMessagingService @@ -29,6 +32,7 @@ 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.InternalBlobStorageRepository import com.flipcash.services.internal.repositories.InternalChatRepository import com.flipcash.services.internal.repositories.InternalEventStreamingRepository import com.flipcash.services.internal.repositories.InternalChatMessagingRepository @@ -52,6 +56,7 @@ import com.flipcash.services.repository.ModerationRepository import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.repository.PurchaseRepository import com.flipcash.services.repository.PushRepository +import com.flipcash.services.repository.BlobStorageRepository import com.flipcash.services.repository.ResolverRepository import com.flipcash.services.repository.SettingsRepository import com.flipcash.services.repository.ThirdPartyRepository @@ -172,6 +177,16 @@ internal object FlipcashModule { service: ResolverService, ): ResolverRepository = InternalResolverRepository(service) + @Provides + internal fun providesBlobStorageRepository( + service: BlobStorageService, + ): BlobStorageRepository = InternalBlobStorageRepository(service) + + @Provides + internal fun providesBlobUploader( + uploader: HttpBlobUploader, + ): BlobUploader = uploader + @Provides internal fun providesAccountRepository( service: AccountService, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/HttpBlobUploader.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/HttpBlobUploader.kt new file mode 100644 index 000000000..dd4ade22f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/HttpBlobUploader.kt @@ -0,0 +1,57 @@ +package com.flipcash.services.internal.network + +import com.flipcash.services.BlobUploader +import com.flipcash.services.models.blob.UploadTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class HttpBlobUploader @Inject constructor() : BlobUploader { + + private val client = OkHttpClient() + + override suspend fun upload( + bytes: ByteArray, + mimeType: String, + target: UploadTarget, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val media = mimeType.toMediaTypeOrNull() + val request = when (target.method) { + UploadTarget.Method.PUT -> { + Request.Builder() + .url(target.url) + .apply { target.headers.forEach { (k, v) -> header(k, v) } } + .put(bytes.toRequestBody(media)) + .build() + } + UploadTarget.Method.POST -> { + // multipart/form-data: the signed policy form fields MUST precede the file part. + val body = MultipartBody.Builder().setType(MultipartBody.FORM).apply { + target.formFields.forEach { (k, v) -> addFormDataPart(k, v) } + addFormDataPart("file", "upload", bytes.toRequestBody(media)) + }.build() + Request.Builder() + .url(target.url) + .apply { target.headers.forEach { (k, v) -> header(k, v) } } + .post(body) + .build() + } + UploadTarget.Method.UNKNOWN -> + throw IllegalStateException("Unknown upload method for target ${target.url}") + } + + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "Blob upload failed: HTTP ${response.code}" } + } + Unit + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt new file mode 100644 index 000000000..a36755cb0 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt @@ -0,0 +1,102 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.blob.v1.BlobStorageGrpcKt +import com.codeinc.flipcash.gen.blob.v1.BlobStorageService as RpcBlobStorageService +import com.codeinc.flipcash.gen.blob.v1.Model +import com.codeinc.flipcash.gen.blob.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.models.chat.BlobId +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.utils.toByteString +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 + +/** + * Wraps the BlobStorage gRPC service — direct-to-storage uploads. The bytes never travel through + * gRPC: [initiateExternalUpload] reserves a [Model.BlobId] and returns a presigned target the client + * uploads to over plain HTTP, [completeExternalUpload] advises the server the upload finished, and + * [getBlobs] resolves ids to their status + a fresh download URL. + */ +@Singleton +internal class BlobStorageApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = BlobStorageGrpcKt.BlobStorageCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun getUploadPolicy(owner: Ed25519.KeyPair): RpcBlobStorageService.GetUploadPolicyResponse { + val request = RpcBlobStorageService.GetUploadPolicyRequest.newBuilder() + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getUploadPolicy(request) + } + } + + suspend fun initiateExternalUpload( + mimeType: String, + sizeBytes: Long, + owner: Ed25519.KeyPair, + ): RpcBlobStorageService.InitiateExternalUploadResponse { + val request = RpcBlobStorageService.InitiateExternalUploadRequest.newBuilder() + .setMimeType(mimeType) + .setSizeBytes(sizeBytes) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.initiateExternalUpload(request) + } + } + + suspend fun completeExternalUpload( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): RpcBlobStorageService.CompleteExternalUploadResponse { + val request = RpcBlobStorageService.CompleteExternalUploadRequest.newBuilder() + .setBlobId(blobId.toProto()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.completeExternalUpload(request) + } + } + + suspend fun getBlobs( + blobIds: List, + owner: Ed25519.KeyPair, + ): RpcBlobStorageService.GetBlobsResponse { + val request = RpcBlobStorageService.GetBlobsRequest.newBuilder() + .setBlobIds( + Model.BlobIdBatch.newBuilder() + .addAllBlobIds(blobIds.map { it.toProto() }) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getBlobs(request) + } + } + + private fun BlobId.toProto(): Model.BlobId = + Model.BlobId.newBuilder().setValue(bytes.toByteString()).build() +} 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 7df913305..bf7059311 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 @@ -25,6 +25,10 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.blob.ImageConstraints +import com.flipcash.services.models.blob.MimeTypeConstraints +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.blob.UploadTarget import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.BlobMetadata import com.flipcash.services.models.chat.BlobRejection @@ -51,6 +55,8 @@ 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.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import com.codeinc.flipcash.gen.activity.v1.Model as ActivityModels import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel @@ -392,6 +398,40 @@ internal fun com.codeinc.flipcash.gen.blob.v1.Model.Blob.toBlobState(): BlobStat ) } +internal fun com.codeinc.flipcash.gen.blob.v1.Model.UploadPolicy.toUploadPolicy(): UploadPolicy { + return UploadPolicy( + version = version.value, + ttl = ttl.seconds.seconds + ttl.nanos.nanoseconds, + mimeTypeConstraints = mimeTypeConstraintsList.map { constraint -> + MimeTypeConstraints( + mimeTypePattern = constraint.mimeTypePattern, + maxSizeBytes = constraint.maxSizeBytes, + image = if (constraint.hasImage()) { + ImageConstraints( + maxWidth = constraint.image.maxWidth, + maxHeight = constraint.image.maxHeight, + maxPixels = constraint.image.maxPixels, + ) + } else null, + ) + }, + ) +} + +internal fun com.codeinc.flipcash.gen.blob.v1.Model.UploadTarget.toUploadTarget(): UploadTarget { + return UploadTarget( + method = when (method) { + com.codeinc.flipcash.gen.blob.v1.Model.UploadTarget.Method.PUT -> UploadTarget.Method.PUT + com.codeinc.flipcash.gen.blob.v1.Model.UploadTarget.Method.POST -> UploadTarget.Method.POST + else -> UploadTarget.Method.UNKNOWN + }, + url = url, + headers = headersMap, + formFields = formFieldsMap, + expiresAt = Instant.fromEpochSeconds(expiresAt.seconds, expiresAt.nanos.toLong()), + ) +} + internal fun com.codeinc.flipcash.gen.blob.v1.Model.BlobStatus.toBlobStatus(): BlobStatus { return when (this) { com.codeinc.flipcash.gen.blob.v1.Model.BlobStatus.BLOB_STATUS_PENDING -> BlobStatus.PENDING diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/BlobStorageService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/BlobStorageService.kt new file mode 100644 index 000000000..7c57f6b29 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/BlobStorageService.kt @@ -0,0 +1,127 @@ +package com.flipcash.services.internal.network.services + +import com.codeinc.flipcash.gen.blob.v1.BlobStorageService as RpcBlobStorageService +import com.flipcash.services.internal.network.api.BlobStorageApi +import com.flipcash.services.internal.network.extensions.toBlobState +import com.flipcash.services.internal.network.extensions.toBlobStatus +import com.flipcash.services.internal.network.extensions.toUploadPolicy +import com.flipcash.services.internal.network.extensions.toUploadTarget +import com.flipcash.services.models.CompleteExternalUploadError +import com.flipcash.services.models.GetBlobsError +import com.flipcash.services.models.GetUploadPolicyError +import com.flipcash.services.models.InitiateExternalUploadError +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.blob.UploadReservation +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobState +import com.flipcash.services.models.chat.BlobStatus +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import javax.inject.Inject + +internal class BlobStorageService @Inject constructor( + private val api: BlobStorageApi, +) { + + suspend fun getUploadPolicy(owner: Ed25519.KeyPair): Result { + return runCatching { api.getUploadPolicy(owner) } + .foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcBlobStorageService.GetUploadPolicyResponse.Result.OK -> + Result.success(response.policy.toUploadPolicy()) + RpcBlobStorageService.GetUploadPolicyResponse.Result.DENIED -> + Result.failure(GetUploadPolicyError.Denied()) + else -> Result.failure(GetUploadPolicyError.Unrecognized()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetUploadPolicyError.Other(cause = it) }) + } + ) + } + + suspend fun initiateExternalUpload( + mimeType: String, + sizeBytes: Long, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { api.initiateExternalUpload(mimeType, sizeBytes, owner) } + .foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcBlobStorageService.InitiateExternalUploadResponse.Result.OK -> + Result.success( + UploadReservation( + blobId = BlobId(response.blobId.value.toByteArray()), + target = response.uploadTarget.toUploadTarget(), + ) + ) + RpcBlobStorageService.InitiateExternalUploadResponse.Result.UNSUPPORTED_TYPE -> + Result.failure(InitiateExternalUploadError.UnsupportedType(response.policyVersionOrNull())) + RpcBlobStorageService.InitiateExternalUploadResponse.Result.TOO_LARGE -> + Result.failure(InitiateExternalUploadError.TooLarge(response.policyVersionOrNull())) + RpcBlobStorageService.InitiateExternalUploadResponse.Result.QUOTA_EXCEEDED -> + Result.failure(InitiateExternalUploadError.QuotaExceeded()) + RpcBlobStorageService.InitiateExternalUploadResponse.Result.DENIED -> + Result.failure(InitiateExternalUploadError.Denied()) + else -> Result.failure(InitiateExternalUploadError.Unrecognized()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { InitiateExternalUploadError.Other(cause = it) }) + } + ) + } + + suspend fun completeExternalUpload( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { api.completeExternalUpload(blobId, owner) } + .foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcBlobStorageService.CompleteExternalUploadResponse.Result.OK -> + Result.success(response.status.toBlobStatus()) + RpcBlobStorageService.CompleteExternalUploadResponse.Result.NOT_FOUND -> + Result.failure(CompleteExternalUploadError.NotFound()) + RpcBlobStorageService.CompleteExternalUploadResponse.Result.NOT_UPLOADED -> + Result.failure(CompleteExternalUploadError.NotUploaded()) + else -> Result.failure(CompleteExternalUploadError.Unrecognized()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { CompleteExternalUploadError.Other(cause = it) }) + } + ) + } + + suspend fun getBlobs( + blobIds: List, + owner: Ed25519.KeyPair, + ): Result> { + return runCatching { api.getBlobs(blobIds, owner) } + .foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcBlobStorageService.GetBlobsResponse.Result.OK -> + Result.success( + if (response.hasBlobs()) response.blobs.blobsList.map { it.toBlobState() } + else emptyList() + ) + RpcBlobStorageService.GetBlobsResponse.Result.DENIED -> + Result.failure(GetBlobsError.Denied()) + else -> Result.failure(GetBlobsError.Unrecognized()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetBlobsError.Other(cause = it) }) + } + ) + } + + private fun RpcBlobStorageService.InitiateExternalUploadResponse.policyVersionOrNull(): String? = + if (hasPolicyVersion()) policyVersion.value else null +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalBlobStorageRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalBlobStorageRepository.kt new file mode 100644 index 000000000..95dfba528 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalBlobStorageRepository.kt @@ -0,0 +1,39 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.BlobStorageService +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.blob.UploadReservation +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobState +import com.flipcash.services.models.chat.BlobStatus +import com.flipcash.services.repository.BlobStorageRepository +import com.getcode.ed25519.Ed25519 +import com.getcode.utils.ErrorUtils + +internal class InternalBlobStorageRepository( + private val service: BlobStorageService, +) : BlobStorageRepository { + + override suspend fun getUploadPolicy(owner: Ed25519.KeyPair): Result = + service.getUploadPolicy(owner) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun initiateExternalUpload( + mimeType: String, + sizeBytes: Long, + owner: Ed25519.KeyPair, + ): Result = service.initiateExternalUpload(mimeType, sizeBytes, owner) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun completeExternalUpload( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): Result = service.completeExternalUpload(blobId, owner) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getBlobs( + blobIds: List, + owner: Ed25519.KeyPair, + ): Result> = service.getBlobs(blobIds, owner) + .onFailure { ErrorUtils.handleError(it) } +} 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 dfab0a611..87cbc685a 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 @@ -492,4 +492,53 @@ sealed class GetReactionSummariesError( class Denied : GetReactionSummariesError("Denied") class Unrecognized : GetReactionSummariesError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : GetReactionSummariesError(message = cause?.message, cause = cause), NotifiableError -} \ No newline at end of file +} +sealed class InitiateExternalUploadError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Denied : InitiateExternalUploadError("Denied") + // policyVersion is echoed by the server on a policy-driven denial so the client can detect a + // stale cached upload policy and re-fetch it. + class UnsupportedType(val policyVersion: String? = null) : InitiateExternalUploadError("Unsupported type") + class TooLarge(val policyVersion: String? = null) : InitiateExternalUploadError("Too large") + class QuotaExceeded : InitiateExternalUploadError("Quota exceeded") + class Unrecognized : InitiateExternalUploadError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : InitiateExternalUploadError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class CompleteExternalUploadError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class NotFound : CompleteExternalUploadError("Not found") + class NotUploaded : CompleteExternalUploadError("Not uploaded") + class Unrecognized : CompleteExternalUploadError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : CompleteExternalUploadError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetBlobsError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Denied : GetBlobsError("Denied") + class Unrecognized : GetBlobsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetBlobsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetUploadPolicyError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Denied : GetUploadPolicyError("Denied") + class Unrecognized : GetUploadPolicyError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetUploadPolicyError(message = cause?.message, cause = cause), NotifiableError +} + +// Thrown when a reserved blob failed server-side finalization (moderation / decode / size). +// Terminal: the client must reserve a fresh upload to retry. +class BlobRejectedException(val rejection: com.flipcash.services.models.chat.BlobRejection?) : + Exception("Blob rejected: ${rejection?.reason}") + +// Thrown when a blob did not reach READY within the client's polling window. +class BlobNotReadyException : Exception("Blob did not become ready in time") diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index e5c4c6ef2..18dedcc08 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -16,6 +16,15 @@ data class UserProfile( /** The email address only when it has been verified — backwards-compatible accessor. */ val verifiedEmailAddress: String? get() = email?.takeIf { it.verified }?.value + + companion object { + val Empty = UserProfile( + displayName = null, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + } } sealed interface SocialAccount { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadPolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadPolicy.kt new file mode 100644 index 000000000..6a7239808 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadPolicy.kt @@ -0,0 +1,52 @@ +package com.flipcash.services.models.blob + +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +/** + * The upload constraints the server enforces, surfaced so the client can filter selection and + * resize/transcode BEFORE reserving an upload. Advisory and cacheable ([ttl] / [version]); + * `initiateExternalUpload` remains authoritative. + */ +@Serializable +data class UploadPolicy( + val version: String, + val ttl: Duration, + // Ordered most-specific-first: the first entry whose pattern matches wins. + val mimeTypeConstraints: List, +) { + /** The constraints governing [mimeType], or null if the type is not accepted. */ + fun constraintsFor(mimeType: String): MimeTypeConstraints? = + mimeTypeConstraints.firstOrNull { it.matches(mimeType) } + + /** Whether [mimeType] is accepted for upload at all. */ + fun accepts(mimeType: String): Boolean = constraintsFor(mimeType) != null + + /** Concrete (non-wildcard) MIME types the policy explicitly names — useful for selection filters. */ + val explicitMimeTypes: List + get() = mimeTypeConstraints + .map { it.mimeTypePattern } + .filter { !it.contains('*') } +} + +@Serializable +data class MimeTypeConstraints( + // "image/jpeg" (exact), "image/*" (subtype wildcard), or "*/*" (catch-all). + val mimeTypePattern: String, + val maxSizeBytes: Long, + val image: ImageConstraints?, +) { + fun matches(mimeType: String): Boolean = when { + mimeTypePattern == "*/*" -> true + mimeTypePattern.endsWith("/*") -> + mimeType.substringBefore('/').equals(mimeTypePattern.substringBefore('/'), ignoreCase = true) + else -> mimeTypePattern.equals(mimeType, ignoreCase = true) + } +} + +@Serializable +data class ImageConstraints( + val maxWidth: Int, + val maxHeight: Int, + val maxPixels: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadTarget.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadTarget.kt new file mode 100644 index 000000000..b98aaa311 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadTarget.kt @@ -0,0 +1,33 @@ +package com.flipcash.services.models.blob + +import com.flipcash.services.models.chat.BlobId +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * A short-lived, presigned target for a direct-to-storage upload. The client issues the described + * HTTP request (PUT raw body, or POST multipart with [formFields]) straight to [url] — the server + * never proxies the bytes. Treat it as a bearer credential: do not persist or share it, and re-mint + * via [BlobStorageController.initiateExternalUpload][com.flipcash.services.controllers.BlobStorageController.initiateExternalUpload] + * once [expiresAt] passes. + */ +@Serializable +data class UploadTarget( + val method: Method, + val url: String, + val headers: Map, + val formFields: Map, + val expiresAt: Instant, +) { + enum class Method { UNKNOWN, PUT, POST } +} + +/** + * The result of reserving an upload: the durable [blobId] handle to reference the bytes once + * uploaded, and the presigned [target] to upload them to. + */ +@Serializable +data class UploadReservation( + val blobId: BlobId, + val target: UploadTarget, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/BlobStorageRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/BlobStorageRepository.kt new file mode 100644 index 000000000..3ad63c036 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/BlobStorageRepository.kt @@ -0,0 +1,22 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.blob.UploadReservation +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobState +import com.flipcash.services.models.chat.BlobStatus +import com.getcode.ed25519.Ed25519 + +interface BlobStorageRepository { + suspend fun getUploadPolicy(owner: Ed25519.KeyPair): Result + + suspend fun initiateExternalUpload( + mimeType: String, + sizeBytes: Long, + owner: Ed25519.KeyPair, + ): Result + + suspend fun completeExternalUpload(blobId: BlobId, owner: Ed25519.KeyPair): Result + + suspend fun getBlobs(blobIds: List, owner: Ed25519.KeyPair): Result> +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt new file mode 100644 index 000000000..d23f5afb9 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt @@ -0,0 +1,168 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.BlobUploader +import com.flipcash.services.models.BlobNotReadyException +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.blob.UploadReservation +import com.flipcash.services.models.blob.UploadTarget +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobState +import com.flipcash.services.models.chat.BlobStatus +import com.flipcash.services.repository.BlobStorageRepository +import com.flipcash.services.user.UserManager +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.accounts.AccountCluster +import io.mockk.coEvery +import io.mockk.coVerify +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.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class BlobStorageControllerTest { + + private val repository = mockk() + private val uploader = mockk() + private val userManager = mockk(relaxed = true) + private val controller = BlobStorageController(repository, uploader, userManager) + + private val blobId = BlobId(ByteArray(16) { 1 }) + private val target = UploadTarget( + method = UploadTarget.Method.PUT, + url = "https://storage.example/upload", + headers = emptyMap(), + formFields = emptyMap(), + expiresAt = Instant.fromEpochSeconds(10_000), + ) + + 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 blobState(status: BlobStatus) = + BlobState(id = blobId, status = status, metadata = null, rejection = null) + + private fun happyPathStubs() { + coEvery { repository.initiateExternalUpload(any(), any(), any()) } returns + Result.success(UploadReservation(blobId, target)) + coEvery { uploader.upload(any(), any(), any()) } returns Result.success(Unit) + coEvery { repository.completeExternalUpload(any(), any()) } returns Result.success(BlobStatus.PROCESSING) + } + + @Test + fun `upload fails when there is no account cluster`() = runTest { + every { userManager.accountCluster } returns null + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertTrue(result.isFailure) + coVerify(exactly = 0) { repository.initiateExternalUpload(any(), any(), any()) } + } + + @Test + fun `upload returns the blob id once READY`() = runTest { + stubOwner() + happyPathStubs() + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.READY))) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertEquals(blobId, result.getOrNull()) + } + + @Test + fun `upload polls until the blob becomes READY`() = runTest { + stubOwner() + happyPathStubs() + coEvery { repository.getBlobs(any(), any()) } returnsMany listOf( + Result.success(listOf(blobState(BlobStatus.PENDING))), + Result.success(listOf(blobState(BlobStatus.PROCESSING))), + Result.success(listOf(blobState(BlobStatus.READY))), + ) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertEquals(blobId, result.getOrNull()) + coVerify(exactly = 3) { repository.getBlobs(any(), any()) } + } + + @Test + fun `upload fails with BlobRejectedException when the blob is REJECTED`() = runTest { + stubOwner() + happyPathStubs() + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.REJECTED))) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `upload times out with BlobNotReadyException when never READY`() = runTest { + stubOwner() + happyPathStubs() + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.PROCESSING))) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `upload fails and does not PUT when initiate fails`() = runTest { + stubOwner() + coEvery { repository.initiateExternalUpload(any(), any(), any()) } returns + Result.failure(RuntimeException("initiate denied")) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertTrue(result.isFailure) + coVerify(exactly = 0) { uploader.upload(any(), any(), any()) } + } + + @Test + fun `upload fails and does not complete when the byte upload fails`() = runTest { + stubOwner() + coEvery { repository.initiateExternalUpload(any(), any(), any()) } returns + Result.success(UploadReservation(blobId, target)) + coEvery { uploader.upload(any(), any(), any()) } returns Result.failure(RuntimeException("network")) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertTrue(result.isFailure) + coVerify(exactly = 0) { repository.completeExternalUpload(any(), any()) } + } + + @Test + fun `upload still succeeds when the advisory complete call fails`() = runTest { + stubOwner() + coEvery { repository.initiateExternalUpload(any(), any(), any()) } returns + Result.success(UploadReservation(blobId, target)) + coEvery { uploader.upload(any(), any(), any()) } returns Result.success(Unit) + coEvery { repository.completeExternalUpload(any(), any()) } returns + Result.failure(RuntimeException("complete failed")) + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.READY))) + + val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") + + assertEquals(blobId, result.getOrNull()) + } + + @Test + fun `getUploadPolicy fails without an account`() = runTest { + every { userManager.accountCluster } returns null + + assertTrue(controller.getUploadPolicy().isFailure) + coVerify(exactly = 0) { repository.getUploadPolicy(any()) } + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/blob/UploadPolicyTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/blob/UploadPolicyTest.kt new file mode 100644 index 000000000..4a7e9136d --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/blob/UploadPolicyTest.kt @@ -0,0 +1,75 @@ +package com.flipcash.services.models.blob + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours + +class UploadPolicyTest { + + private fun constraint(pattern: String, maxSize: Long = 1_000) = + MimeTypeConstraints(mimeTypePattern = pattern, maxSizeBytes = maxSize, image = null) + + private fun policy(vararg constraints: MimeTypeConstraints) = + UploadPolicy(version = "v1", ttl = 1.hours, mimeTypeConstraints = constraints.toList()) + + @Test + fun `exact pattern matches only that type, case-insensitively`() { + val c = constraint("image/jpeg") + assertTrue(c.matches("image/jpeg")) + assertTrue(c.matches("IMAGE/JPEG")) + assertFalse(c.matches("image/png")) + } + + @Test + fun `subtype wildcard matches same top-level type only`() { + val c = constraint("image/*") + assertTrue(c.matches("image/png")) + assertTrue(c.matches("image/jpeg")) + assertFalse(c.matches("video/mp4")) + } + + @Test + fun `catch-all matches anything`() { + val c = constraint("*/*") + assertTrue(c.matches("image/png")) + assertTrue(c.matches("application/pdf")) + } + + @Test + fun `constraintsFor returns the first matching entry (most-specific-first)`() { + val exact = constraint("image/png", maxSize = 100) + val wildcard = constraint("image/*", maxSize = 999) + val p = policy(exact, wildcard) + + assertSame(exact, p.constraintsFor("image/png")) + assertSame(wildcard, p.constraintsFor("image/jpeg")) + } + + @Test + fun `constraintsFor is null for an unsupported type`() { + val p = policy(constraint("image/*")) + assertNull(p.constraintsFor("video/mp4")) + } + + @Test + fun `accepts reflects constraintsFor`() { + val p = policy(constraint("image/*")) + assertTrue(p.accepts("image/png")) + assertFalse(p.accepts("text/plain")) + } + + @Test + fun `explicitMimeTypes excludes wildcard patterns`() { + val p = policy( + constraint("image/jpeg"), + constraint("image/png"), + constraint("image/*"), + constraint("*/*"), + ) + assertEquals(listOf("image/jpeg", "image/png"), p.explicitMimeTypes) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cc49fa420..66325b905 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -82,6 +82,7 @@ include( ":apps:flipcash:shared:tokens:core", ":apps:flipcash:shared:theme", ":apps:flipcash:shared:profile", + ":apps:flipcash:shared:blob", ":apps:flipcash:shared:userflags", ":apps:flipcash:shared:workers", ":apps:flipcash:shared:web", @@ -111,6 +112,7 @@ include( ":apps:flipcash:features:transactions", ":apps:flipcash:features:bill-customization", ":apps:flipcash:features:discovery", + ":apps:flipcash:features:user-profile", ":apps:flipcash:features:userflags", // protobuf model and service implementations for the Open Code Protocol diff --git a/ui/resources/src/main/java/com/getcode/util/resources/ContentReader.kt b/ui/resources/src/main/java/com/getcode/util/resources/ContentReader.kt index b66ef37ce..319ec358e 100644 --- a/ui/resources/src/main/java/com/getcode/util/resources/ContentReader.kt +++ b/ui/resources/src/main/java/com/getcode/util/resources/ContentReader.kt @@ -5,16 +5,50 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.net.Uri +import android.webkit.MimeTypeMap import androidx.exifinterface.media.ExifInterface import java.io.File import androidx.core.graphics.scale interface ContentReader { fun readBytes(uri: Uri): ByteArray? - fun copyToCache(uri: Uri, fileName: String, maxSize: Int = Int.MAX_VALUE): Uri? + /** The content MIME type of [uri] (e.g. "image/jpeg"), or null if it can't be resolved. */ + fun mimeType(uri: Uri): String? + /** + * Re-encodes [uri] into the cache, downscaled to [maxSize] and stripped of EXIF/orientation + * metadata. [mimeType] (the source type) selects the output format — JPEG is preserved, + * everything else is written as PNG. Use [uploadMimeFor] to get the resulting type. + */ + fun copyToCache(uri: Uri, fileName: String, maxSize: Int = Int.MAX_VALUE, mimeType: String? = null): Uri? fun removeFromCache(uri: Uri) } +private const val EXTENSION_JPG = "jpg" +private const val EXTENSION_JPEG = "jpeg" +private const val EXTENSION_PNG = "png" + +/** + * The MIME type the re-encoded [copyToCache] bytes will actually be for a given source type. Derived + * via [MimeTypeMap] (no hardcoded MIME literals): a JPEG source stays JPEG, everything else is PNG. + */ +fun uploadMimeFor(sourceMimeType: String?): String { + val extension = if (compressFormatFor(sourceMimeType) == Bitmap.CompressFormat.JPEG) { + EXTENSION_JPEG + } else { + EXTENSION_PNG + } + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension).orEmpty() +} + +private fun compressFormatFor(sourceMimeType: String?): Bitmap.CompressFormat { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(sourceMimeType?.lowercase()) + return if (extension == EXTENSION_JPG || extension == EXTENSION_JPEG) { + Bitmap.CompressFormat.JPEG + } else { + Bitmap.CompressFormat.PNG + } +} + class AndroidContentReader(private val context: Context) : ContentReader { override fun readBytes(uri: Uri): ByteArray? { return try { @@ -24,7 +58,14 @@ class AndroidContentReader(private val context: Context) : ContentReader { } } - override fun copyToCache(uri: Uri, fileName: String, maxSize: Int): Uri? { + override fun mimeType(uri: Uri): String? { + // ContentResolver knows content:// types; fall back to MimeTypeMap for file:// URIs. + context.contentResolver.getType(uri)?.let { return it } + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) + } + + override fun copyToCache(uri: Uri, fileName: String, maxSize: Int, mimeType: String?): Uri? { val bytes = readBytes(uri) ?: return null val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null @@ -54,8 +95,10 @@ class AndroidContentReader(private val context: Context) : ContentReader { oriented } + val format = compressFormatFor(mimeType) + val quality = if (format == Bitmap.CompressFormat.JPEG) 90 else 100 val file = File(context.cacheDir, fileName) - file.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + file.outputStream().use { scaled.compress(format, quality, it) } return Uri.fromFile(file) }