From 1a7ab4a1291aee18cf191817771be1f1509f3cc3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 22 Jul 2026 13:36:29 -0400 Subject: [PATCH 1/2] feat(user-profile): enforce upload policy on photo selection Gate profile photo picks against the server UploadPolicy before caching and upload: - Reject MIME types the policy doesn't accept (checked against the re-encoded upload type, not the source, so HEIC/WebP that normalize into an accepted format still pass). - Derive the downscale target from the policy's dimension + pixel caps (min of maxWidth, maxHeight, and sqrt(maxPixels)) instead of a hardcoded 500px; falls back to 500 when no constraints are named. - Enforce maxSizeBytes on the re-encoded output before upload, adding ContentReader.size() for the measurement. Fails open when the policy hasn't loaded; the server remains authoritative. Rejections clear the preview and surface an alert. --- .../core/src/main/res/values/strings.xml | 3 + .../internal/photo/PhotoSelectionViewModel.kt | 58 ++++++++++++++++++- .../getcode/util/resources/ContentReader.kt | 16 +++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b787f077c..7ee11574c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -722,6 +722,9 @@ This Image is Not Supported Try a different image format + This Image is Too Large + Try a smaller image + This Description is Not Allowed Try a different currency description 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 index a793ddee9..7a7de62ec 100644 --- 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 @@ -1,11 +1,13 @@ package com.flipcash.app.userprofile.internal.photo import android.net.Uri +import androidx.annotation.StringRes 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.ImageConstraints import com.flipcash.services.models.blob.UploadPolicy import com.flipcash.features.userprofile.R import com.flipcash.libs.coroutines.DispatcherProvider @@ -33,6 +35,8 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.math.floor +import kotlin.math.sqrt import kotlin.time.Duration.Companion.milliseconds @HiltViewModel @@ -87,14 +91,38 @@ class PhotoSelectionViewModel @Inject constructor( .filterIsInstance() .mapNotNull { event -> val sourceMime = contentReader.mimeType(event.image) + // The cache re-encodes to JPEG/PNG, so gate on the type we'd actually upload — + // not the source type, which may normalize into an accepted format (e.g. HEIC → PNG). + val uploadMime = uploadMimeFor(sourceMime) + val policy = stateFlow.value.uploadPolicy + val constraints = policy?.constraintsFor(uploadMime) + if (policy != null && constraints == null) { + rejectImage( + title = R.string.error_title_imageNotSupported, + message = R.string.error_description_imageNotSupported, + ) + return@mapNotNull null + } + // Downscale to honor the policy's dimension + pixel caps. copyToCache bounds the + // longest edge, so the smallest of (maxWidth, maxHeight, √maxPixels) satisfies all three. val cached = contentReader.copyToCache( uri = event.image, fileName = "user_profile_${System.nanoTime()}", - maxSize = 500, + maxSize = maxEdgeFor(constraints?.image), mimeType = sourceMime, ) ?: return@mapNotNull null + // Enforce the byte ceiling on the re-encoded output before it rides to the server. + val maxBytes = constraints?.maxSizeBytes + if (maxBytes != null && (contentReader.size(cached) ?: 0L) > maxBytes) { + contentReader.removeFromCache(cached) + rejectImage( + title = R.string.error_title_imageTooLarge, + message = R.string.error_description_imageTooLarge, + ) + return@mapNotNull null + } // The cache re-encodes (stripping EXIF); declare the type those bytes actually are. - cached to uploadMimeFor(sourceMime) + cached to uploadMime } .flowOn(dispatchers.IO) .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } @@ -165,8 +193,34 @@ class PhotoSelectionViewModel @Inject constructor( .launchIn(viewModelScope) } + /** Clears the pending selection and surfaces [title]/[message] to the user. */ + private fun rejectImage(@StringRes title: Int, @StringRes message: Int) { + dispatchEvent(Event.OnImageCleared) + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + } + + /** + * The longest-edge cap that satisfies every dimension constraint in [image]: the smallest of + * maxWidth, maxHeight, and √maxPixels (bounding the longest edge by √maxPixels keeps total area + * ≤ maxPixels). Falls back to [DEFAULT_MAX_EDGE] when the policy names no image constraints. + */ + private fun maxEdgeFor(image: ImageConstraints?): Int { + val caps = listOfNotNull( + image?.maxWidth, + image?.maxHeight, + image?.maxPixels?.let { floor(sqrt(it.toDouble())).toInt() }, + ).filter { it > 0 } + return caps.minOrNull() ?: DEFAULT_MAX_EDGE + } + companion object { + // Longest-edge downscale target used when the upload policy specifies no dimension caps. + private const val DEFAULT_MAX_EDGE = 500 + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { Event.CheckImage -> { state -> state } 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 319ec358e..44e425d31 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 @@ -21,6 +21,8 @@ interface ContentReader { */ fun copyToCache(uri: Uri, fileName: String, maxSize: Int = Int.MAX_VALUE, mimeType: String? = null): Uri? fun removeFromCache(uri: Uri) + /** The size of [uri]'s content in bytes, or null if it can't be resolved. */ + fun size(uri: Uri): Long? } private const val EXTENSION_JPG = "jpg" @@ -105,4 +107,18 @@ class AndroidContentReader(private val context: Context) : ContentReader { override fun removeFromCache(uri: Uri) { uri.path?.let { File(it).delete() } } + + override fun size(uri: Uri): Long? { + // file:// (our cache) — measure the file directly; otherwise ask the resolver. + if (uri.scheme == "file") { + uri.path?.let { File(it).takeIf(File::exists)?.length()?.let { len -> return len } } + } + return try { + context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { fd -> + fd.length.takeIf { it >= 0 } + } + } catch (_: java.io.FileNotFoundException) { + null + } + } } From 615802c57bf52dbade3965b205cd1e9d4cc6d80a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 14:36:27 -0400 Subject: [PATCH 2/2] feat(user-profile): sealed BlobState + granular name/photo moderation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate BlobState to a sealed Ready/Rejected model and update all consumers, and surface per-category moderation feedback in the name and photo flows. BlobState (sealed): - toBlobState() now yields BlobState? — READY -> Ready(metadata), REJECTED -> Rejected(reason), and non-terminal PENDING/PROCESSING/ UNKNOWN -> null (callers keep polling). A REJECTED blob missing its reason falls back to UNKNOWN rather than being dropped. - getBlobs uses mapNotNull so a still-processing id resolves to an empty list; awaitReady switches on the sealed type. - BlobRejectedException takes a non-null BlobRejection. Name/photo error handling: - Photo: map BlobRejectedException by RejectionReason, and MODERATION by FlaggedCategory, to specific copy; other terminal reasons show a generic failure. - Name: rely on SetDisplayNameError.FailedModerated and branch on FlaggedCategory for specific copy. - Add per-category name/photo strings; fix subtitle typo. Photo selection also resizes to fit the policy: shrink the longest edge until the re-encoded bytes fit maxSizeBytes rather than rejecting, with a last-resort reject only if the smallest re-encode still overflows. --- .../core/src/main/res/values/strings.xml | 13 +- .../internal/name/NameEntryViewModel.kt | 87 ++++--- .../internal/photo/PhotoSelectionViewModel.kt | 212 ++++++++++++++---- .../controllers/BlobStorageController.kt | 11 +- .../network/extensions/ProtobufToLocal.kt | 32 ++- .../network/services/BlobStorageService.kt | 4 +- .../com/flipcash/services/models/Errors.kt | 9 +- .../services/models/chat/BlobUpdate.kt | 19 +- .../controllers/BlobStorageControllerTest.kt | 28 ++- 9 files changed, 311 insertions(+), 104 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 7ee11574c..d5a30c0b4 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -868,8 +868,19 @@ Your Name This Name is Not Allowed Try a different name + AI flagged this name. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + AI flagged this name as sexually explicit. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + AI flagged this name for impersonation. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + AI flagged this name as misleading. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + AI flagged this name as spam. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + This Photo is Not Allowed + AI flagged this photo. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X + AI flagged this photo as sexually explicit. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X + AI flagged this photo for impersonation. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X + AI flagged this photo as misleading. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X + AI flagged this photo as spam. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X Upload Your Photo - CThis photo will be shown when receiving tips + This photo will be shown when receiving tips Your Name 500x500 Recommended My Tip Card 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 index 665cb2c3c..58514d15a 100644 --- 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 @@ -9,6 +9,7 @@ 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.SetDisplayNameError import com.flipcash.services.models.TextModerationError import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager @@ -69,16 +70,7 @@ class NameEntryViewModel @Inject constructor( .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 { + .map { profileController.setDisplayName(stateFlow.value.nameFieldState.text.toString()) }.onResult( onSuccess = { @@ -91,27 +83,68 @@ class NameEntryViewModel @Inject constructor( }, 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), - ) - } - } + handleNameSetFailure(cause) } ).launchIn(viewModelScope) } + private fun handleNameSetFailure(cause: Throwable) { + when (cause) { + is SetDisplayNameError.FailedModerated -> { + when (cause.category) { + ModerationResult.FlaggedCategory.NONE -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowed) + ) + } + ModerationResult.FlaggedCategory.OTHER -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowedFlaggedOther) + ) + } + ModerationResult.FlaggedCategory.NSFW -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowedFlaggedNsfw) + ) + } + ModerationResult.FlaggedCategory.IMPERSONATION -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowedFlaggedImpersonation) + ) + } + ModerationResult.FlaggedCategory.MISLEADING -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowedFlaggedMisleading) + ) + } + ModerationResult.FlaggedCategory.SPAM -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profileNameNotAllowed), + message = resources.getString(R.string.error_description_profileNameNotAllowedFlaggedSpam) + ) + } + } + } + is ValidationException -> { + 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), + ) + } + } + } companion object { private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { 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 index 7a7de62ec..0d8a2cb88 100644 --- 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 @@ -13,9 +13,11 @@ 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.BlobRejectedException import com.flipcash.services.models.ImageModerationError import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.TextModerationError +import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.errors.ValidationException @@ -28,6 +30,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -103,16 +106,16 @@ class PhotoSelectionViewModel @Inject constructor( ) return@mapNotNull null } - // Downscale to honor the policy's dimension + pixel caps. copyToCache bounds the - // longest edge, so the smallest of (maxWidth, maxHeight, √maxPixels) satisfies all three. - val cached = contentReader.copyToCache( + // Re-encode within the policy's dimension + pixel caps, then keep shrinking the + // longest edge until the bytes fit maxSizeBytes — resize to fit, don't reject. + val maxBytes = constraints?.maxSizeBytes + val cached = cacheWithinPolicy( uri = event.image, - fileName = "user_profile_${System.nanoTime()}", - maxSize = maxEdgeFor(constraints?.image), - mimeType = sourceMime, + sourceMime = sourceMime, + image = constraints?.image, + maxBytes = maxBytes, ) ?: return@mapNotNull null - // Enforce the byte ceiling on the re-encoded output before it rides to the server. - val maxBytes = constraints?.maxSizeBytes + // Last resort: if even the smallest re-encode can't meet the byte ceiling, reject. if (maxBytes != null && (contentReader.size(cached) ?: 0L) > maxBytes) { contentReader.removeFromCache(cached) rejectImage( @@ -132,20 +135,13 @@ class PhotoSelectionViewModel @Inject constructor( .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 { + .map { // 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")) + ?: return@map Result.failure(IllegalStateException("No image selected")) val bytes = contentReader.readBytes(uri) - ?: return@flatMapResult Result.failure(IllegalStateException("Unable to read image")) + ?: return@map Result.failure(IllegalStateException("Unable to read image")) blobStorage.upload(bytes = bytes, mimeType = stateFlow.value.imageMimeType) } .flatMapResult { blobId -> @@ -164,30 +160,7 @@ class PhotoSelectionViewModel @Inject constructor( 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), - ) - } - } + handleUploadFailure(cause) } ) .launchIn(viewModelScope) @@ -202,6 +175,43 @@ class PhotoSelectionViewModel @Inject constructor( ) } + /** + * Re-encodes [uri] into the cache honoring [image]'s dimension caps, then shrinks the + * longest-edge target until the output fits [maxBytes] — resizing to fit rather than rejecting. + * Returns null only if re-encoding fails outright; otherwise the smallest attempt (which the + * caller re-checks, since a byte ceiling smaller than [MIN_MAX_EDGE] can produce is pathological). + */ + private fun cacheWithinPolicy( + uri: Uri, + sourceMime: String?, + image: ImageConstraints?, + maxBytes: Long?, + ): Uri? { + var edge = maxEdgeFor(image) + var last: Uri? = null + repeat(MAX_RESIZE_ATTEMPTS) { + // Drop the previous over-ceiling attempt before making a smaller one. + last?.let { contentReader.removeFromCache(it) } + val candidate = contentReader.copyToCache( + uri = uri, + fileName = "user_profile_${System.nanoTime()}", + maxSize = edge, + mimeType = sourceMime, + ) ?: return null + last = candidate + val size = contentReader.size(candidate) ?: 0L + if (maxBytes == null || size <= maxBytes) return candidate + // Encoded bytes track pixel area (edge²); scale the edge by √(ceiling/actual) with a + // safety margin to converge, floored at MIN_MAX_EDGE. + val next = floor(edge * sqrt(maxBytes.toDouble() / size) * RESIZE_SAFETY) + .toInt() + .coerceAtLeast(MIN_MAX_EDGE) + if (next >= edge) return candidate // already at the floor — hand back the best effort + edge = next + } + return last + } + /** * The longest-edge cap that satisfies every dimension constraint in [image]: the smallest of * maxWidth, maxHeight, and √maxPixels (bounding the longest edge by √maxPixels keeps total area @@ -216,11 +226,129 @@ class PhotoSelectionViewModel @Inject constructor( return caps.minOrNull() ?: DEFAULT_MAX_EDGE } + private fun handleUploadFailure(cause: Throwable) { + when (cause) { + is BlobRejectedException -> { + when (cause.rejection.reason) { + RejectionReason.UNKNOWN -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed) + ) + } + RejectionReason.MODERATION -> { + when (cause.rejection.flaggedCategory) { + ModerationResult.FlaggedCategory.NONE -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed) + ) + } + + ModerationResult.FlaggedCategory.OTHER -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profilePhotoNotAllowed), + message = resources.getString(R.string.error_description_profilePhotoNotAllowedFlaggedOther) + ) + } + + ModerationResult.FlaggedCategory.NSFW -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profilePhotoNotAllowed), + message = resources.getString(R.string.error_description_profilePhotoNotAllowedFlaggedNsfw) + ) + } + + ModerationResult.FlaggedCategory.IMPERSONATION -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profilePhotoNotAllowed), + message = resources.getString(R.string.error_description_profilePhotoNotAllowedFlaggedImpersonation) + ) + } + + ModerationResult.FlaggedCategory.MISLEADING -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profilePhotoNotAllowed), + message = resources.getString(R.string.error_description_profilePhotoNotAllowedFlaggedMisleading) + ) + } + + ModerationResult.FlaggedCategory.SPAM -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_profilePhotoNotAllowed), + message = resources.getString(R.string.error_description_profilePhotoNotAllowedFlaggedSpam) + ) + } + } + } + RejectionReason.UNSUPPORTED_TYPE -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + RejectionReason.MISMATCHED_TYPE -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + RejectionReason.TOO_LARGE -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + RejectionReason.CORRUPT -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + RejectionReason.INTERNAL -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + RejectionReason.PRIVACY_METADATA -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + } + } + is ValidationException -> { + 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), + ) + } + } + } + companion object { // Longest-edge downscale target used when the upload policy specifies no dimension caps. private const val DEFAULT_MAX_EDGE = 500 + // Floor for the resize-to-fit loop — below this a profile image is no longer worth keeping. + private const val MIN_MAX_EDGE = 64 + + // How many times to shrink-and-retry before handing back the smallest attempt. + private const val MAX_RESIZE_ATTEMPTS = 5 + + // Under-shoot the estimated fitting edge so re-encode overhead doesn't push us back over. + private const val RESIZE_SAFETY = 0.9 + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { Event.CheckImage -> { state -> state } 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 index 8ee55f36a..222401e5e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/BlobStorageController.kt @@ -5,7 +5,7 @@ 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.models.chat.BlobState import com.flipcash.services.repository.BlobStorageRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -66,10 +66,11 @@ class BlobStorageController @Inject constructor( .getOrElse { return Result.failure(it) } .firstOrNull() - when (blob?.status) { - BlobStatus.READY -> return Result.success(blobId) - BlobStatus.REJECTED -> return Result.failure(BlobRejectedException(blob.rejection)) - else -> { + when (blob) { + is BlobState.Ready -> return Result.success(blobId) + is BlobState.Rejected -> return Result.failure(BlobRejectedException(blob.reason)) + // null — still pending/processing (non-terminal states resolve to null); keep polling. + null -> { delay(POLL_INTERVAL) elapsed += POLL_INTERVAL } 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 bf7059311..649bc0ac2 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 @@ -385,19 +385,35 @@ internal fun EventModel.ChatUpdate.toChatUpdate( internal fun EventModel.BlobUpdate.toBlobUpdate(): BlobUpdate { return BlobUpdate( - blobs = blobs.blobsList.map { it.toBlobState() }, + blobs = blobs.blobsList.mapNotNull { it.toBlobState() }, ) } -internal fun com.codeinc.flipcash.gen.blob.v1.Model.Blob.toBlobState(): BlobState { - return BlobState( - id = BlobId(id.value.toByteArray()), - status = status.toBlobStatus(), - metadata = if (hasMetadata()) metadata.toBlobMetadata() else null, - rejection = if (hasRejection()) rejection.toBlobRejection() else null, - ) +/** + * Maps a proto blob to its terminal [BlobState], or null while non-terminal. The sealed model only + * represents the two outcomes clients act on — READY (metadata populated) and REJECTED (reason + * present); PENDING/PROCESSING/UNKNOWN carry no client-facing payload, so they collapse to null + * (callers keep polling / waiting for the next update). + */ +internal fun com.codeinc.flipcash.gen.blob.v1.Model.Blob.toBlobState(): BlobState? { + val blobId = BlobId(id.value.toByteArray()) + return when (status.toBlobStatus()) { + BlobStatus.READY -> + if (hasMetadata()) BlobState.Ready(blobId, metadata.toBlobMetadata()) else null + BlobStatus.REJECTED -> + BlobState.Rejected(blobId, if (hasRejection()) rejection.toBlobRejection() else UNKNOWN_REJECTION) + BlobStatus.UNKNOWN, + BlobStatus.PENDING, + BlobStatus.PROCESSING -> null + } } +// A REJECTED blob is terminal even if the server omitted the reason; fall back rather than drop it. +private val UNKNOWN_REJECTION = BlobRejection( + reason = RejectionReason.UNKNOWN, + flaggedCategory = ModerationResult.FlaggedCategory.NONE, +) + internal fun com.codeinc.flipcash.gen.blob.v1.Model.UploadPolicy.toUploadPolicy(): UploadPolicy { return UploadPolicy( version = version.value, 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 index 7c57f6b29..cee1b0696 100644 --- 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 @@ -108,7 +108,9 @@ internal class BlobStorageService @Inject constructor( when (response.result) { RpcBlobStorageService.GetBlobsResponse.Result.OK -> Result.success( - if (response.hasBlobs()) response.blobs.blobsList.map { it.toBlobState() } + // Drop non-terminal blobs (toBlobState → null) so a still-processing + // id resolves to an empty list and the caller keeps polling. + if (response.hasBlobs()) response.blobs.blobsList.mapNotNull { it.toBlobState() } else emptyList() ) RpcBlobStorageService.GetBlobsResponse.Result.DENIED -> 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 87cbc685a..a420093e3 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 @@ -1,5 +1,6 @@ package com.flipcash.services.models +import com.flipcash.services.models.chat.BlobRejection import com.getcode.solana.keys.Checksum import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError @@ -258,7 +259,7 @@ sealed class TextModerationError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Flagged(category: ModerationResult.FlaggedCategory) : TextModerationError("Content flagged: $category") + class Flagged(val category: ModerationResult.FlaggedCategory) : TextModerationError("Content flagged: $category") class Denied : TextModerationError("Denied") class UnsupportedLanguage: TextModerationError("Unsupported Language") class Unrecognized : TextModerationError("Unrecognized"), NotifiableError @@ -269,7 +270,7 @@ sealed class ImageModerationError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Flagged(category: ModerationResult.FlaggedCategory) : TextModerationError("Content flagged: $category") + class Flagged(val category: ModerationResult.FlaggedCategory) : TextModerationError("Content flagged: $category") class Denied : ImageModerationError("Denied") class UnsupportedFormat: ImageModerationError("Unsupported Format") @@ -537,8 +538,8 @@ sealed class GetUploadPolicyError( // 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}") +class BlobRejectedException(val rejection: 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/chat/BlobUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobUpdate.kt index 74511eb51..e64dfde4a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobUpdate.kt @@ -12,15 +12,20 @@ data class BlobUpdate( // A single blob's current status, plus its metadata once READY — or, once // REJECTED, the reason it was rejected. -data class BlobState( +sealed class BlobState( val id: BlobId, val status: BlobStatus, - // Server-authoritative metadata (including a freshly minted download URL). - // Set only when status == READY. - val metadata: BlobMetadata?, - // Why the blob was rejected. Set only when status == REJECTED. - val rejection: BlobRejection?, -) +) { + class Ready( + id: BlobId, + val metadata: BlobMetadata, + ) : BlobState(id, BlobStatus.READY) + class Rejected( + id: BlobId, + val reason: BlobRejection, + ) : BlobState(id, BlobStatus.REJECTED) + +} enum class BlobStatus { UNKNOWN, 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 index d23f5afb9..a7e7cfcad 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/BlobStorageControllerTest.kt @@ -5,9 +5,12 @@ 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.ModerationResult import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobRejection import com.flipcash.services.models.chat.BlobState import com.flipcash.services.models.chat.BlobStatus +import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.repository.BlobStorageRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -49,8 +52,13 @@ class BlobStorageControllerTest { every { userManager.accountCluster } returns cluster } - private fun blobState(status: BlobStatus) = - BlobState(id = blobId, status = status, metadata = null, rejection = null) + // The sealed model only represents terminal outcomes; non-terminal polls surface as an empty + // getBlobs list (the service filters PENDING/PROCESSING out), so tests model those with emptyList(). + private fun readyBlob() = BlobState.Ready(id = blobId, metadata = mockk(relaxed = true)) + private fun rejectedBlob() = BlobState.Rejected( + id = blobId, + reason = BlobRejection(RejectionReason.UNKNOWN, ModerationResult.FlaggedCategory.NONE), + ) private fun happyPathStubs() { coEvery { repository.initiateExternalUpload(any(), any(), any()) } returns @@ -73,7 +81,7 @@ class BlobStorageControllerTest { fun `upload returns the blob id once READY`() = runTest { stubOwner() happyPathStubs() - coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.READY))) + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(readyBlob())) val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") @@ -85,9 +93,10 @@ class BlobStorageControllerTest { 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))), + // Non-terminal polls resolve to an empty list; the controller keeps polling. + Result.success(emptyList()), + Result.success(emptyList()), + Result.success(listOf(readyBlob())), ) val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") @@ -100,7 +109,7 @@ class BlobStorageControllerTest { 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))) + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(rejectedBlob())) val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") @@ -111,7 +120,8 @@ class BlobStorageControllerTest { fun `upload times out with BlobNotReadyException when never READY`() = runTest { stubOwner() happyPathStubs() - coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(blobState(BlobStatus.PROCESSING))) + // Never terminal — every poll resolves to an empty list until the timeout trips. + coEvery { repository.getBlobs(any(), any()) } returns Result.success(emptyList()) val result = controller.upload(byteArrayOf(1, 2, 3), "image/png") @@ -151,7 +161,7 @@ class BlobStorageControllerTest { 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))) + coEvery { repository.getBlobs(any(), any()) } returns Result.success(listOf(readyBlob())) val result = controller.upload(byteArrayOf(1, 2, 3), "image/png")