Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.snabble.sdk.ui.cart.shoppingcart.image

import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale

/**
* Loads [imageUrl] through the authenticated okHttpClient of the given snabble project and renders it.
*
* The caller owns sizing and shape via [modifier] and must state the intended render resolution
* explicitly via [targetSizePx] (the longest edge, in pixels) so the bitmap can be downsampled to it.
* [placeholder] is shown while loading and [error] on failure; both default to empty so the space is
* simply held by [modifier].
*/
@Composable
internal fun RemoteImage(
imageUrl: String,
targetSizePx: Int,
modifier: Modifier = Modifier,
projectId: String? = null,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Crop,
placeholder: @Composable BoxScope.() -> Unit = {},
error: @Composable BoxScope.() -> Unit = placeholder,
) {
val state: RemoteImageState by produceState<RemoteImageState>(
RemoteImageState.Loading, imageUrl, projectId, targetSizePx
) {
value = RemoteImageState.Loading
val bitmap = loadRemoteImage(imageUrl, projectId, targetSizePx)
value = if (bitmap != null) RemoteImageState.Success(bitmap) else RemoteImageState.Error
}

Box(modifier = modifier) {
when (val current = state) {
RemoteImageState.Loading -> placeholder()
RemoteImageState.Error -> error()
is RemoteImageState.Success -> Image(
modifier = Modifier.matchParentSize(),
bitmap = current.bitmap.asImageBitmap(),
contentDescription = contentDescription,
contentScale = contentScale,
)
}
}
}

private sealed interface RemoteImageState {
data object Loading : RemoteImageState
data class Success(val bitmap: Bitmap) : RemoteImageState
data object Error : RemoteImageState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package io.snabble.sdk.ui.cart.shoppingcart.image

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import io.snabble.sdk.Snabble
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.resume

private const val BYTES_PER_KILOBYTE = 1024

/** Fraction of the available heap to spend on the bitmap cache (1 / [HEAP_CACHE_DIVISOR]). */
private const val HEAP_CACHE_DIVISOR = 8

/** inSampleSize must be a power of two, so it is stepped by this factor. */
private const val SAMPLE_SIZE_STEP = 2

/** No downsampling / smallest valid inSampleSize. */
private const val MIN_SAMPLE_SIZE = 1

private val cacheSizeKilobytes =
(Runtime.getRuntime().maxMemory() / BYTES_PER_KILOBYTE / HEAP_CACHE_DIVISOR).toInt()

/**
* In-memory cache for downloaded and downsampled bitmaps. Keyed by url + target size, since the same
* url may be requested at different resolutions.
*/
private val bitmapCache = object : LruCache<String, Bitmap>(cacheSizeKilobytes) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / BYTES_PER_KILOBYTE
}

/**
* Downloads [imageUrl] through the authenticated okHttpClient of the given project, so the correct
* per-project token is attached even when tokens/projects change at runtime.
*
* @param projectId the project whose client (and token) to use; falls back to the checked-in project.
* @param targetSizePx the longest edge the caller intends to render at; used to downsample the bitmap.
* Pass <= 0 to decode at full resolution.
* @return the decoded bitmap, or null on any failure (no project, no client, network error, decode error).
*/
internal suspend fun loadRemoteImage(imageUrl: String, projectId: String?, targetSizePx: Int): Bitmap? {
val resolvedProjectId = projectId ?: Snabble.checkedInProject.value?.id ?: return null
val cacheKey = "$imageUrl@$targetSizePx"
bitmapCache.get(cacheKey)?.let { return it }

val client = Snabble.projects.firstOrNull { it.id == resolvedProjectId }?.okHttpClient ?: return null
return downloadBitmap(client, imageUrl, targetSizePx)?.also { bitmapCache.put(cacheKey, it) }
}

private suspend fun downloadBitmap(client: OkHttpClient, imageUrl: String, targetSizePx: Int): Bitmap? =
suspendCancellableCoroutine { continuation ->
val call = client.newCall(Request.Builder().get().url(imageUrl).build())
continuation.invokeOnCancellation { call.cancel() }

call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) continuation.resume(null)
}

override fun onResponse(call: Call, response: Response) {
// Reading the body or decoding may throw (IOException, OutOfMemoryError); swallow it and
// resume with null so a single bad image degrades to the placeholder instead of leaking
// the throwable onto OkHttp's thread and hanging the coroutine.
val bitmap = try {
response.use { resp ->
if (!resp.isSuccessful) null else decodeSampledBitmap(resp.body.bytes(), targetSizePx)
}
} catch (_: Exception) {
null
} catch (_: OutOfMemoryError) {
null
}
if (continuation.isActive) continuation.resume(bitmap)
}
})
}

private fun decodeSampledBitmap(bytes: ByteArray, targetSizePx: Int): Bitmap? {
if (targetSizePx <= 0) return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)

val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)

val options = BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, targetSizePx)
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}

private fun calculateInSampleSize(width: Int, height: Int, targetSizePx: Int): Int {
if (width <= 0 || height <= 0) return MIN_SAMPLE_SIZE
var sampleSize = MIN_SAMPLE_SIZE
val longestEdge = maxOf(width, height)
while (longestEdge / (sampleSize * SAMPLE_SIZE_STEP) >= targetSizePx) {
sampleSize *= SAMPLE_SIZE_STEP
}
return sampleSize
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ internal fun Product(
) {
ProductImage(
imageUrl = cartItem.imageUrl,
name = cartItem.name,
contentDescription = cartItem.name,
showPlaceholder = cartItem.showPlaceHolder,
isAgeRestricted = cartItem.isAgeRestricted,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,78 +1,90 @@
package io.snabble.sdk.ui.cart.shoppingcart.product.widget

import android.graphics.drawable.Drawable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import io.snabble.sdk.ui.cart.shoppingcart.image.RemoteImage

private val ImageSize = 48.dp
private val ImageCornerRadius = 4.dp

private const val HUE_DEGREES = 360
private const val PLACEHOLDER_SATURATION = 0.5f
private const val PLACEHOLDER_BRIGHTNESS = 0.6f

@OptIn(ExperimentalGlideComposeApi::class)
@Composable
internal fun ProductImage(
imageUrl: String?,
name: String?,
contentDescription: String?,
showPlaceholder: Boolean,
isAgeRestricted: Boolean,
age: Int
) {
if (imageUrl == null && !showPlaceholder && !(isAgeRestricted && age > 0)) return
val hasAgeBadge = isAgeRestricted && age > 0
if (imageUrl == null && !showPlaceholder && !hasAgeBadge) return

Box(modifier = Modifier.wrapContentSize()) {
if (imageUrl != null) {
var hasFailed by remember(imageUrl) { mutableStateOf(false) }
val imageModifier = Modifier
.size(ImageSize)
.clip(RoundedCornerShape(ImageCornerRadius))

if (hasFailed) return

GlideImage(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp)),
model = imageUrl,
if (imageUrl != null) {
val targetSizePx = with(LocalDensity.current) { ImageSize.roundToPx() }
RemoteImage(
imageUrl = imageUrl,
targetSizePx = targetSizePx,
modifier = imageModifier,
contentDescription = contentDescription,
) { requestBuilder ->
requestBuilder.addListener(
object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean,
): Boolean {
hasFailed = true
return false
}

override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>,
dataSource: DataSource,
isFirstResource: Boolean,
): Boolean = false
}
)
}
} else if (showPlaceholder) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp)),
placeholder = { LetterPlaceholder(name) },
error = { LetterPlaceholder(name) },
)
} else if (showPlaceholder) {
Box(modifier = imageModifier) { LetterPlaceholder(name) }
}
AgeRestrictionIcon(isAgeRestricted, age)
}
}

/**
* Fallback shown while an image loads or when it is missing: a colored box, tinted deterministically
* from [name], with the product's leading letter centered on top.
*/
@Composable
private fun BoxScope.LetterPlaceholder(name: String?) {
val color = remember(name) { placeholderColor(name) }
val letter = name?.trim()?.firstOrNull()?.uppercaseChar()?.toString().orEmpty()

Box(
modifier = Modifier
.matchParentSize()
.background(color),
contentAlignment = Alignment.Center,
) {
if (letter.isNotEmpty()) {
Text(
text = letter,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
}
}

private fun placeholderColor(name: String?): Color {
val hue = ((name?.hashCode() ?: 0) % HUE_DEGREES).let { if (it < 0) it + HUE_DEGREES else it }.toFloat()
return Color.hsv(hue, PLACEHOLDER_SATURATION, PLACEHOLDER_BRIGHTNESS)
}
Loading