From 73f572a0a3d65ece67d37e3168aa19ff3b2cff74 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 21 Jul 2026 18:46:29 -0400 Subject: [PATCH 1/5] feat(services): blob storage upload client + upload policy Adds the client wrapper for the BlobStorage gRPC service (direct-to-storage presigned uploads): Api/Service/Repository/Controller, an HTTP BlobUploader, the UploadTarget/UploadPolicy domain models + proto converters, error types, and DI wiring. BlobStorageController.upload() coordinates the full handshake (reserve -> PUT/POST -> complete -> poll until READY) behind one call, and getUploadPolicy() exposes the accepted MIME/size constraints. Covered by UploadPolicyTest and BlobStorageControllerTest. Signed-off-by: Brandon McAnsh --- .../com/flipcash/services/BlobUploader.kt | 12 ++ .../controllers/BlobStorageController.kt | 85 +++++++++ .../services/inject/FlipcashModule.kt | 15 ++ .../internal/network/HttpBlobUploader.kt | 57 ++++++ .../internal/network/api/BlobStorageApi.kt | 102 +++++++++++ .../network/extensions/ProtobufToLocal.kt | 40 +++++ .../network/services/BlobStorageService.kt | 127 +++++++++++++ .../InternalBlobStorageRepository.kt | 39 ++++ .../com/flipcash/services/models/Errors.kt | 51 +++++- .../services/models/blob/UploadPolicy.kt | 52 ++++++ .../services/models/blob/UploadTarget.kt | 33 ++++ .../repository/BlobStorageRepository.kt | 22 +++ .../controllers/BlobStorageControllerTest.kt | 168 ++++++++++++++++++ .../services/models/blob/UploadPolicyTest.kt | 75 ++++++++ 14 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/BlobUploader.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/HttpBlobUploader.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/BlobStorageService.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalBlobStorageRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadPolicy.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/blob/UploadTarget.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/repository/BlobStorageRepository.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/blob/UploadPolicyTest.kt 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/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/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) + } +} From cb15428de1277481b029f03f50654c9098c72b7a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 21 Jul 2026 18:55:06 -0400 Subject: [PATCH 2/5] feat(chat): type-aware DM feed (CONTACT_DM / TIP_DM) Makes the shared chat FeedOperations type-aware: feed(chatType) and observeUnreadConversations(chatType) now take a required ChatType, and the sync delegate merges the CONTACT_DM and TIP_DM feeds while only applying the contact identity-drop to CONTACT_DM. Send flow consumes feed(CONTACT_DM); the contact list carries a ConversationReference instead of a raw Conversation. Session/nav rename the generic notificationUnreadCount to contactDmUnreadCount now that unread is tracked per chat type. Covered by FeedSyncCombinedFeedTest. Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/core/ui/NavigationBar.kt | 6 +- .../directsend/internal/ContactListBuilder.kt | 3 +- .../directsend/internal/ContactListItem.kt | 11 +-- .../directsend/internal/SendFlowViewModel.kt | 5 +- .../screens/components/ContactList.kt | 18 ++--- .../ui/components/ScannerNavigationBar.kt | 2 +- .../shared/chat/ui/ConversationReference.kt | 11 +++ .../flipcash/shared/chat/ChatCoordinator.kt | 9 ++- .../internal/delegates/FeedSyncDelegate.kt | 79 ++++++++++++------- .../shared/chat/FeedSyncCombinedFeedTest.kt | 64 +++++++++++++++ .../flipcash/app/session/SessionController.kt | 2 +- .../session/internal/RealSessionController.kt | 5 +- 12 files changed, 150 insertions(+), 65 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt 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/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/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/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/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..2382decea 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,7 @@ import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.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 @@ -194,9 +195,9 @@ 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) appSettingsCoordinator From 950853f1d910bafd2a3f83bed37b510e701dc35d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 21 Jul 2026 18:58:01 -0400 Subject: [PATCH 3/5] feat(blob): blob storage coordinator + upload-policy cache Adds the app-layer BlobStorageCoordinator (new :apps:flipcash:shared:blob module) wrapping BlobStorageController. It owns a DataStore-backed upload-policy cache, exposes policy as a self-refreshing Flow (TTL + version invalidation), and offers preloadPolicy()/upload(). The session controller preloads the policy once the user is registered so photo selection can filter/validate locally without a round-trip. Covered by BlobStorageCoordinatorTest. Signed-off-by: Brandon McAnsh --- apps/flipcash/shared/blob/build.gradle.kts | 21 +++ .../app/blob/BlobStorageCoordinator.kt | 161 ++++++++++++++++++ .../app/blob/BlobStorageCoordinatorTest.kt | 102 +++++++++++ apps/flipcash/shared/session/build.gradle.kts | 1 + .../session/internal/RealSessionController.kt | 11 ++ settings.gradle.kts | 1 + 6 files changed, 297 insertions(+) create mode 100644 apps/flipcash/shared/blob/build.gradle.kts create mode 100644 apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/BlobStorageCoordinator.kt create mode 100644 apps/flipcash/shared/blob/src/test/kotlin/com/flipcash/app/blob/BlobStorageCoordinatorTest.kt 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/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/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 2382decea..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,7 @@ import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.internal.bill.BillController @@ -100,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, @@ -200,6 +202,15 @@ class RealSessionController @Inject constructor( .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 .observeValue(AppSettingValue.CameraStartByDefault) .onEach { autoStart -> stateHolder.update { it.copy(autoStartCamera = autoStart) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index cc49fa420..d63359bad 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", From 330edea0047bcee0706b12ed87eae7d4b70e26c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 21 Jul 2026 19:00:37 -0400 Subject: [PATCH 4/5] feat(user-profile): reusable name + photo edit flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the :apps:flipcash:features:user-profile module — a stepped flow for setting/replacing the display name and profile photo, drivable for either or both steps (edit vs first-time setup) via AppRoute.UpdateUserProfile. Photo selection reads the source MIME type, moderates, uploads through the BlobStorageCoordinator, and sets the picture; name entry moderates and sets the display name. ProfileController now merges the result into the local UserProfile so the change is reflected immediately. ContentReader gains MIME-type detection and format-aware, EXIF-stripped cache copies. Signed-off-by: Brandon McAnsh --- apps/flipcash/app/build.gradle.kts | 1 + .../ui/navigation/AppScreenContent.kt | 7 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 35 ++- .../core/userprofile/UpdateProfileResult.kt | 20 ++ .../app/core/userprofile/UpdateProfileStep.kt | 19 ++ .../core/src/main/res/values/strings.xml | 9 + .../flipcash/features/user-profile/.gitignore | 2 + .../features/user-profile/build.gradle.kts | 23 ++ .../userprofile/UserProfileSetupFlowScreen.kt | 66 ++++++ .../internal/name/NameEntryScreen.kt | 139 +++++++++++ .../internal/name/NameEntryViewModel.kt | 133 +++++++++++ .../internal/photo/PhotoSelectionScreen.kt | 221 ++++++++++++++++++ .../internal/photo/PhotoSelectionViewModel.kt | 197 ++++++++++++++++ .../services/controllers/ProfileController.kt | 11 + .../flipcash/services/models/UserProfile.kt | 9 + settings.gradle.kts | 1 + .../getcode/util/resources/ContentReader.kt | 49 +++- 17 files changed, 934 insertions(+), 8 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileResult.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt create mode 100644 apps/flipcash/features/user-profile/.gitignore create mode 100644 apps/flipcash/features/user-profile/build.gradle.kts create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt 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/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/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/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/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/settings.gradle.kts b/settings.gradle.kts index d63359bad..66325b905 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -112,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) } From e6ce9ab871d18d5bbc3612807f653b01554ff769 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 21 Jul 2026 19:02:52 -0400 Subject: [PATCH 5/5] feat(myaccount): editable profile header (name + photo) Reworks the account profile screen into an avatar-over-name header: tapping the avatar launches the photo edit flow, tapping the pencil beside the name launches the name edit flow (both via AppRoute.UpdateUserProfile). The view model now surfaces the profile picture as a MediaItem and emits edit-name / edit-photo events instead of exposing methods. Signed-off-by: Brandon McAnsh --- .../app/myaccount/UserProfileScreen.kt | 28 +++++ .../internal/UserProfileScreenContent.kt | 100 ++++++++++++------ .../internal/UserProfileViewModel.kt | 13 +++ .../ContactMethodsViewModelStateTest.kt | 2 + 4 files changed, 109 insertions(+), 34 deletions(-) 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,