diff --git a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImage.kt b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImage.kt new file mode 100644 index 000000000..626127490 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImage.kt @@ -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.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 +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImageLoader.kt b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImageLoader.kt new file mode 100644 index 000000000..fae39e28f --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/image/RemoteImageLoader.kt @@ -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(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 +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/Product.kt b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/Product.kt index ada7580fd..4858aaac5 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/Product.kt +++ b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/Product.kt @@ -37,6 +37,7 @@ internal fun Product( ) { ProductImage( imageUrl = cartItem.imageUrl, + name = cartItem.name, contentDescription = cartItem.name, showPlaceholder = cartItem.showPlaceHolder, isAgeRestricted = cartItem.isAgeRestricted, diff --git a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/ProductImage.kt b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/ProductImage.kt index 946fe2775..86f59baaf 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/ProductImage.kt +++ b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/product/widget/ProductImage.kt @@ -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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean, - ): Boolean { - hasFailed = true - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target, - 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) +}