diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index b787f077c..d5a30c0b4 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
@@ -865,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 a793ddee9..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
@@ -1,19 +1,23 @@
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
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
@@ -26,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
@@ -33,6 +38,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 +94,38 @@ class PhotoSelectionViewModel @Inject constructor(
.filterIsInstance()
.mapNotNull { event ->
val sourceMime = contentReader.mimeType(event.image)
- val cached = contentReader.copyToCache(
+ // 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
+ }
+ // 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 = 500,
- mimeType = sourceMime,
+ sourceMime = sourceMime,
+ image = constraints?.image,
+ maxBytes = maxBytes,
) ?: return@mapNotNull null
+ // 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(
+ 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)) }
@@ -104,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 ->
@@ -136,37 +160,195 @@ 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)
- )
- }
+ handleUploadFailure(cause)
+ }
+ )
+ .launchIn(viewModelScope)
+ }
- is ImageModerationError.UnsupportedFormat -> {
- BottomBarManager.showAlert(
- title = resources.getString(R.string.error_title_imageNotSupported),
- message = resources.getString(R.string.error_description_imageNotSupported)
- )
- }
+ /** 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),
+ )
+ }
+
+ /**
+ * 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
+ * ≤ 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
+ }
+
+ 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)
+ )
+ }
- else -> {
- BottomBarManager.showError(
- title = resources.getString(R.string.error_title_moderationFailed),
- message = resources.getString(R.string.error_description_moderationFailed),
- )
+ 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),
+ )
+ }
}
- )
- .launchIn(viewModelScope)
+ }
+ 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")
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
+ }
+ }
}