Skip to content
Merged
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
Expand Up @@ -22,6 +22,8 @@ import com.flipcash.app.billing.BillingClient
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.LocalContactCoordinator
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.media.LocalMediaUrlResolver
import com.flipcash.app.core.media.MediaUrlResolver
import com.flipcash.app.core.tipping.LocalTipCoordinator
import com.flipcash.app.core.toast.LocalToastController
import com.flipcash.app.core.toast.ToastController
Expand Down Expand Up @@ -142,6 +144,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var tippingCoordinator: TippingCoordinator

@Inject
lateinit var mediaUrlResolver: MediaUrlResolver

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUncaughtException()
Expand Down Expand Up @@ -177,6 +182,7 @@ class MainActivity : FragmentActivity() {
LocalToastController provides toastController,
LocalCoinbaseOnRampController provides coinbaseOnRampController,
LocalTipCoordinator provides tippingCoordinator,
LocalMediaUrlResolver provides mediaUrlResolver,
LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false),
) {
ProvidePermissionChecker(permissionChecker) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.flipcash.app.core.navigation.destinationRoute
import com.flipcash.app.core.ui.NavigationBar
import com.flipcash.app.core.ui.rememberNavigationBarState
import com.flipcash.app.session.LocalSessionController
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.user.AuthState
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.manager.BottomBarManager
Expand Down Expand Up @@ -157,5 +158,13 @@ private fun rememberProfileAvatar(): (@Composable (Modifier) -> Unit)? {

val picture = profile?.profilePicture ?: return null
val displayName = profile?.displayName.orEmpty()
return { modifier -> ContactAvatar(picture, displayName, modifier) }
// The account's own picture, so its blobs are the caller's own — no access context needed.
return { modifier ->
ContactAvatar(
image = picture,
displayName = displayName,
access = BlobAccessContext.Owned,
modifier = modifier,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.flipcash.app.core.media

import android.content.Context
import coil3.SingletonImageLoader
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

/**
* Whether the image loader already holds the bytes filed under a stable cache key. Blob renditions
* are cached by their durable blob id rather than their download URL, so a hit here means the URL
* will never be dereferenced — and an expired one costs nothing.
*/
interface ImageCachePresence {
suspend fun holds(cacheKey: String): Boolean
}

@Singleton
class CoilImageCachePresence @Inject constructor(
@param:ApplicationContext private val context: Context,
) : ImageCachePresence {

override suspend fun holds(cacheKey: String): Boolean = withContext(Dispatchers.IO) {
val loader = SingletonImageLoader.get(context)
if (loader.memoryCache?.keys.orEmpty().any { it.key == cacheKey }) {
return@withContext true
}
// openSnapshot takes a read lock on the entry; close it immediately — we only wanted to
// know it exists, the actual read happens inside Coil's own fetcher.
loader.diskCache?.openSnapshot(cacheKey)?.use { true } ?: false
}
}

@Module
@InstallIn(SingletonComponent::class)
internal abstract class ImageCachePresenceModule {
@Binds
abstract fun bindImageCachePresence(impl: CoilImageCachePresence): ImageCachePresence
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.flipcash.app.core.media

import com.flipcash.app.core.time.TimeProvider
import com.flipcash.services.controllers.BlobStorageController
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.BlobMetadata
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.models.chat.MediaItemRendition
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton

/**
* Hands out download URLs for [MediaItem] renditions that are actually usable.
*
* A blob's bytes are immutable but its `download_url` is a short-lived signed URL carrying an
* expiry; the client is expected to call `GetBlobs` for a fresh one once that passes. Because the
* whole [MediaItem] is persisted (profile pictures live in the `user_profiles` table), a stored URL
* is routinely older than its expiry by the time a surface asks for it, and handing that straight
* to the image loader produces a failed load and a placeholder.
*
* Two things stop this from turning into a request per avatar:
* - renditions whose bytes are already cached are left alone, since their URL is never fetched, and
* - re-mints are memoised for the process lifetime and serialised, so a screenful of surfaces
* sharing a rendition costs one call.
*
* Every entry point takes the [BlobAccessContext] the media is being read from, because that is
* what authorizes a re-mint of a blob the caller doesn't own — another user's avatar resolves to
* nothing without it.
*
* [refreshUrlForSize] is the recovery path for a URL that failed anyway — metadata persisted before
* expiry was modelled carries no expiry at all, so it can only be found stale by trying it.
*/
@Singleton
class MediaUrlResolver @Inject constructor(
private val blobStorage: BlobStorageController,
private val cache: ImageCachePresence,
private val time: TimeProvider,
) {
private val mutex = Mutex()
private val reminted = mutableMapOf<String, BlobMetadata>()

/**
* A usable download URL for the rendition [MediaItem.renditionForSize] picks for
* [targetLongestSidePx], re-minting first if the stored one has expired. Null when the item has
* no rendition for that size. Falls back to the stored URL if a re-mint fails, so an offline
* client still gets whatever chance the cache gives it.
*/
suspend fun urlForSize(
media: MediaItem,
targetLongestSidePx: Int,
access: BlobAccessContext,
): String? {
val rendition = media.renditionForSize(targetLongestSidePx) ?: return null
val stored = rendition.blob ?: return null

reminted[rendition.cacheKey]?.let { return it.downloadUrl }
if (!stored.isDownloadUrlExpired(time.now())) return stored.downloadUrl
if (cache.holds(rendition.cacheKey)) return stored.downloadUrl

return remint(rendition, access, staleUrl = stored.downloadUrl)?.downloadUrl
?: stored.downloadUrl
}

/**
* Re-mints the rendition for [targetLongestSidePx] after [failedUrl] failed to load, returning
* a URL to try instead, or null if the blob could not be resolved. This is the only signal
* available for metadata that carries no expiry — every row persisted before expiry was
* modelled — so a failure is taken to mean the URL was stale.
*
* Passing the URL that actually failed is what keeps this from duplicating work: if another
* caller has already minted a URL past that one, it is returned as-is.
*/
suspend fun refreshUrlForSize(
media: MediaItem,
targetLongestSidePx: Int,
failedUrl: String?,
access: BlobAccessContext,
): String? {
val rendition = media.renditionForSize(targetLongestSidePx) ?: return null
return remint(rendition, access, staleUrl = failedUrl)?.downloadUrl
}

/** Drops memoised URLs — they are minted for the signed-in owner and don't outlive the session. */
suspend fun reset() {
mutex.withLock { reminted.clear() }
}

// Serialised so a screenful of surfaces resolving the same rendition makes one call, not one
// each. Callers that were already waiting take whatever the winner minted, as long as it isn't
// the [staleUrl] they came in holding.
private suspend fun remint(
rendition: MediaItemRendition,
access: BlobAccessContext,
staleUrl: String?,
): BlobMetadata? = mutex.withLock {
reminted[rendition.cacheKey]
?.takeIf { it.downloadUrl != staleUrl }
?.let { return@withLock it }

blobStorage.refreshMetadata(listOf(rendition.blobId), access)
.getOrNull()
?.get(rendition.cacheKey)
?.also { reminted[rendition.cacheKey] = it }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.flipcash.app.core.media

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.MediaItem

val LocalMediaUrlResolver = staticCompositionLocalOf<MediaUrlResolver?> { null }

/**
* The download URL a blob-backed surface should render, and whether rendering it is hopeless.
*
* [url] starts as the URL stored on the [MediaItem] and is replaced if it needs re-minting, so a
* cached rendition draws on the first frame instead of waiting on a resolve. [hasFailed] only goes
* true once a re-minted URL has failed too — a first failure is treated as a stale URL and retried
* silently, leaving whatever placeholder is on screen in place.
*/
@Stable
class MediaUrlState internal constructor(initialUrl: String?) {
var url: String? by mutableStateOf(initialUrl)
internal set

var hasFailed: Boolean by mutableStateOf(false)
internal set

internal var failures by mutableIntStateOf(0)
private set

/** The URL the last failure was reported against — what a re-mint has to improve on. */
internal var failedUrl: String? = null
private set

/** Report that the image loader could not load [url]. */
fun onLoadFailed() {
failedUrl = url
failures++
}
}

/**
* Resolves [media]'s rendition for [targetLongestSidePx] into a URL that can be handed to the image
* loader, keeping it usable as the stored one expires. See [MediaUrlResolver] for why a stored URL
* often isn't. [access] names the surface [media] is being read from — a re-mint of media the
* caller doesn't own resolves to nothing without it.
*/
@Composable
fun rememberMediaUrl(
media: MediaItem?,
targetLongestSidePx: Int,
access: BlobAccessContext,
): MediaUrlState {
val resolver = LocalMediaUrlResolver.current
val state = remember(media, targetLongestSidePx) {
MediaUrlState(media?.urlForSize(targetLongestSidePx))
}

LaunchedEffect(state, resolver) {
if (media == null || resolver == null) return@LaunchedEffect
resolver.urlForSize(media, targetLongestSidePx, access)?.let { state.url = it }
}

LaunchedEffect(state, resolver, state.failures) {
if (state.failures == 0) return@LaunchedEffect
// Only the first failure is worth a re-mint; a fresh URL that also fails is a real error.
if (media == null || resolver == null || state.failures > 1) {
state.hasFailed = true
return@LaunchedEffect
}
val fresh = resolver.refreshUrlForSize(media, targetLongestSidePx, state.failedUrl, access)
if (fresh != null && fresh != state.url) {
state.url = fresh
} else {
state.hasFailed = true
}
}

return state
}
Loading
Loading