Skip to content

Commit e54e14c

Browse files
bmc08gtclaude
andcommitted
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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a36d774 commit e54e14c

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,9 @@
722722
<string name="error_title_imageNotSupported">This Image is Not Supported</string>
723723
<string name="error_description_imageNotSupported">Try a different image format</string>
724724

725+
<string name="error_title_imageTooLarge">This Image is Too Large</string>
726+
<string name="error_description_imageTooLarge">Try a smaller image</string>
727+
725728
<string name="error_title_descriptionNotAllowed">This Description is Not Allowed</string>
726729
<string name="error_description_descriptionNotAllowed">Try a different currency description</string>
727730

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.flipcash.app.userprofile.internal.photo
22

33
import android.net.Uri
4+
import androidx.annotation.StringRes
45
import androidx.lifecycle.viewModelScope
56
import com.flipcash.app.blob.BlobStorageCoordinator
67
import com.flipcash.app.core.data.Loadable
78
import com.flipcash.app.core.extensions.flatMapResult
89
import com.flipcash.app.core.extensions.onResult
10+
import com.flipcash.services.models.blob.ImageConstraints
911
import com.flipcash.services.models.blob.UploadPolicy
1012
import com.flipcash.features.userprofile.R
1113
import com.flipcash.libs.coroutines.DispatcherProvider
@@ -33,6 +35,8 @@ import kotlinx.coroutines.flow.mapNotNull
3335
import kotlinx.coroutines.flow.onEach
3436
import kotlinx.coroutines.launch
3537
import javax.inject.Inject
38+
import kotlin.math.floor
39+
import kotlin.math.sqrt
3640
import kotlin.time.Duration.Companion.milliseconds
3741

3842
@HiltViewModel
@@ -87,14 +91,38 @@ class PhotoSelectionViewModel @Inject constructor(
8791
.filterIsInstance<Event.OnImageSelected>()
8892
.mapNotNull { event ->
8993
val sourceMime = contentReader.mimeType(event.image)
94+
// The cache re-encodes to JPEG/PNG, so gate on the type we'd actually upload —
95+
// not the source type, which may normalize into an accepted format (e.g. HEIC → PNG).
96+
val uploadMime = uploadMimeFor(sourceMime)
97+
val policy = stateFlow.value.uploadPolicy
98+
val constraints = policy?.constraintsFor(uploadMime)
99+
if (policy != null && constraints == null) {
100+
rejectImage(
101+
title = R.string.error_title_imageNotSupported,
102+
message = R.string.error_description_imageNotSupported,
103+
)
104+
return@mapNotNull null
105+
}
106+
// Downscale to honor the policy's dimension + pixel caps. copyToCache bounds the
107+
// longest edge, so the smallest of (maxWidth, maxHeight, √maxPixels) satisfies all three.
90108
val cached = contentReader.copyToCache(
91109
uri = event.image,
92110
fileName = "user_profile_${System.nanoTime()}",
93-
maxSize = 500,
111+
maxSize = maxEdgeFor(constraints?.image),
94112
mimeType = sourceMime,
95113
) ?: return@mapNotNull null
114+
// Enforce the byte ceiling on the re-encoded output before it rides to the server.
115+
val maxBytes = constraints?.maxSizeBytes
116+
if (maxBytes != null && (contentReader.size(cached) ?: 0L) > maxBytes) {
117+
contentReader.removeFromCache(cached)
118+
rejectImage(
119+
title = R.string.error_title_imageTooLarge,
120+
message = R.string.error_description_imageTooLarge,
121+
)
122+
return@mapNotNull null
123+
}
96124
// The cache re-encodes (stripping EXIF); declare the type those bytes actually are.
97-
cached to uploadMimeFor(sourceMime)
125+
cached to uploadMime
98126
}
99127
.flowOn(dispatchers.IO)
100128
.onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) }
@@ -165,8 +193,34 @@ class PhotoSelectionViewModel @Inject constructor(
165193
.launchIn(viewModelScope)
166194
}
167195

196+
/** Clears the pending selection and surfaces [title]/[message] to the user. */
197+
private fun rejectImage(@StringRes title: Int, @StringRes message: Int) {
198+
dispatchEvent(Event.OnImageCleared)
199+
BottomBarManager.showAlert(
200+
title = resources.getString(title),
201+
message = resources.getString(message),
202+
)
203+
}
204+
205+
/**
206+
* The longest-edge cap that satisfies every dimension constraint in [image]: the smallest of
207+
* maxWidth, maxHeight, and √maxPixels (bounding the longest edge by √maxPixels keeps total area
208+
* ≤ maxPixels). Falls back to [DEFAULT_MAX_EDGE] when the policy names no image constraints.
209+
*/
210+
private fun maxEdgeFor(image: ImageConstraints?): Int {
211+
val caps = listOfNotNull(
212+
image?.maxWidth,
213+
image?.maxHeight,
214+
image?.maxPixels?.let { floor(sqrt(it.toDouble())).toInt() },
215+
).filter { it > 0 }
216+
return caps.minOrNull() ?: DEFAULT_MAX_EDGE
217+
}
218+
168219
companion object {
169220

221+
// Longest-edge downscale target used when the upload policy specifies no dimension caps.
222+
private const val DEFAULT_MAX_EDGE = 500
223+
170224
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
171225
when (event) {
172226
Event.CheckImage -> { state -> state }

‎ui/resources/src/main/java/com/getcode/util/resources/ContentReader.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ interface ContentReader {
2121
*/
2222
fun copyToCache(uri: Uri, fileName: String, maxSize: Int = Int.MAX_VALUE, mimeType: String? = null): Uri?
2323
fun removeFromCache(uri: Uri)
24+
/** The size of [uri]'s content in bytes, or null if it can't be resolved. */
25+
fun size(uri: Uri): Long?
2426
}
2527

2628
private const val EXTENSION_JPG = "jpg"
@@ -105,4 +107,18 @@ class AndroidContentReader(private val context: Context) : ContentReader {
105107
override fun removeFromCache(uri: Uri) {
106108
uri.path?.let { File(it).delete() }
107109
}
110+
111+
override fun size(uri: Uri): Long? {
112+
// file:// (our cache) — measure the file directly; otherwise ask the resolver.
113+
if (uri.scheme == "file") {
114+
uri.path?.let { File(it).takeIf(File::exists)?.length()?.let { len -> return len } }
115+
}
116+
return try {
117+
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { fd ->
118+
fd.length.takeIf { it >= 0 }
119+
}
120+
} catch (_: java.io.FileNotFoundException) {
121+
null
122+
}
123+
}
108124
}

0 commit comments

Comments
 (0)