Skip to content

fix(blobs): re-mint expired download URLs before loading them - #1404

Merged
bmc08gt merged 3 commits into
code/cashfrom
fix/blob-url-expiry
Sep 4, 2026
Merged

fix(blobs): re-mint expired download URLs before loading them#1404
bmc08gt merged 3 commits into
code/cashfrom
fix/blob-url-expiry

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Profile photos showed the grey placeholder in the chat header and on the user profile screen while the same photo still rendered in the chats list. The chats list had just been refreshed from GetDmChatFeed; the other two surfaces read a profile persisted earlier, whose CloudFront download URL — signed for about 15 minutes — had long since expired. Coil draws the error drawable on a failed load, not the placeholder, so what looked like "no photo" was a 403.

What changed

Expiry is modelled and persisted. BlobMetadata carries download_url.expires_at as expiresAtMillis through the proto mapping and into the persisted JSON. Every other field on it is intrinsic to the immutable bytes; this one is not, and dropping it left a stored copy with no way to know its URL was dead. Metadata with no expiry is treated as usable, so already-persisted rows don't force a re-resolve on every load.

MediaUrlResolver re-mints before the URL reaches Coil. A singleton that checks the rendition's expiry, calls GetBlobs for a fresh URL, and memoises the result for the process, so several surfaces showing the same avatar re-mint once between them. rememberMediaUrl wires it into composition: a load that fails anyway gets one further re-mint from Coil's error callback, and only a second failure falls back.

BlobAccessContext carries the authorization scope. Re-minting a blob the caller doesn't own needs GetBlobsRequest.context naming the surface that authorizes the read — a profile id for another user's avatar, a chat id for chat media. Without it the server omits those ids from the response rather than failing, which is indistinguishable from a blob that isn't ready. The scope is a required parameter on ContactAvatar's MediaItem overload, so every call site has to state which one it is.

The chat member → UserProfile mapping dropped the user id. The server sets the id on the chat member and not again inside the nested profile, so userId came out null and callers had nothing to name the profile with. Fixed in both mapping paths — toChatMetadata() and the injected ChatMetadataMapper — each with a test that fails when the fallback is removed.

BlurHash instead of the grey glyph. Avatars decode the BlurHash once and use it as both Coil's placeholder and its error drawable, so a re-mint in flight, or a load that fails outright, shows the blurred photo rather than the person icon.

Cache keys are the blob id, not the URL. download_url is minted per fetch, so a URL-keyed cache misses every time even when the bytes are held. Requests key memory and disk on the base58 blob id; notification avatars use the same key and now go through the resolver too.

Notes

  • BlobStorageApi validates the request before attaching the context. dev.bmcreations:protovalidate 0.1.1 generates an AccessContext validator that emits checkRequired(scopeCase == CHAT, "chat") and checkRequired(scopeCase == PROFILE, "profile") unconditionally, so no oneof arm can validate. The comment at the split says to drop it once the generator handles oneof members.
  • MediaUrlResolver is reset on sign-out — download URLs are minted for the signed-in owner.
  • Wallet token icons still show ic_placeholder_user. They load a plain token.imageUrl string through TokenIcon, a different pipeline this branch doesn't touch.

@bmc08gt bmc08gt self-assigned this Sep 4, 2026
@github-actions github-actions Bot added type: fix Bug fix area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: session area: build-system Gradle, convention plugins, build-logic labels Sep 4, 2026
Profile photos showed the grey placeholder in the chat header and on the
user profile screen while the same photo still rendered in the chats list.
The list had just been refreshed from GetDmChatFeed; the other two surfaces
read a profile persisted earlier, whose CloudFront download URL — signed for
about 15 minutes — had long since expired. Coil draws the error drawable on
a failed load, so the placeholder was the 403.

BlobMetadata now carries download_url.expires_at through the proto mapping
and into the persisted JSON, so a stored copy can tell that its URL is dead.
MediaUrlResolver checks that before handing a URL to Coil and calls GetBlobs
to re-mint the id, memoising the result for the process so every surface
showing the same avatar re-mints once. A load that fails anyway gets one
further re-mint from the error callback before giving up.

Re-minting a blob the caller does not own needs GetBlobsRequest.context to
name what authorizes the read, so BlobAccessContext threads a scope (owned,
a profile id, or a chat id) from each avatar call site down to the API.
Several call sites had no user id to pass: the chat member -> UserProfile
mapping dropped it in both mapping paths, leaving the nested profile's
userId null even though the server sets it on the member.

Avatars draw the BlurHash while a re-mint is in flight and when a load fails
outright, rather than the grey person glyph. Coil requests are keyed on the
durable blob id instead of the per-fetch URL, so a re-mint reuses bytes that
are already cached; notification avatars use the same key and now go through
the resolver too.
flipcash2-client-protocol 0.4.1 regenerates the validators with
protovalidate-kt 0.1.2, which checks a oneof arm's required rule only when
that arm is selected. AccessContext can pass client-side validation now, so
the request no longer has to be validated before the context is attached to
avoid it.

The test pins that: a profile scope and a chat scope each validate, and an
unset scope does not. All three fail against 0.4.0.
Gating the actions on the edit and delete windows (#1403) made the
ToggleMessageSelection reducer re-narrow the bubble it selects, so the selection
is a copy of the bubble the event carried. Three assertSame checks were pinning
the old identity and have failed on code/cash since that merge, taking down the
messenger module's test task on every PR.

The narrowing is a no-op under MessagePolicy.Default, whose windows are both
null, so the copy is value-equal and assertEquals asserts what the tests meant.
@bmc08gt
bmc08gt force-pushed the fix/blob-url-expiry branch from 953b526 to fd48d4d Compare September 4, 2026 02:25
@bmc08gt
bmc08gt merged commit 36e7cef into code/cash Sep 4, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: session type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant