Skip to content

fix(avatars): cache profile pictures on disk and reload them when they change - #760

Merged
bmc08gt merged 2 commits into
mainfrom
fix/avatar-image-cache
Sep 11, 2026
Merged

bmc08gt merged 2 commits into
mainfrom
fix/avatar-image-cache

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Profile pictures re-downloaded on every launch, and once downloaded could show the wrong person's photo until the process restarted. Four separate causes, all in how the bytes were held.

What was wrong

Nothing persisted. RemoteImageCache.install() puts .cacheMemoryOnly into Kingfisher's defaultOptions, so RemoteImageLoader — the signed-in user's own picture — never wrote to the disk cache, and its retrieveImage(forKey:) could not hit after a relaunch. URLCache cannot stand in for it here: blobDownloadURL mints a fresh signed URL per fetch, so the same picture arrives under a different key every time. Other users' pictures went through TipAvatarStore, which held raw bytes in memory only and was gone on relaunch.

A changed picture never replaced the old one. TipAvatarStore.load guarded on dataByUser[userID] == nil. A user who changes their picture keeps their user id and gets a new blob id, so that guard reads as a hit and the fetch never runs. ContactAvatarCache had the same shape of bug one layer down — it keyed decoded images by contact id and ignored its data argument on a hit, which left the old photo on screen for the rest of the process on every avatar surface, since they all read through it.

UserProfileScreen captured the bytes once. It read the store at body-eval time and fed the result into a @State model built once, so a picture that arrived after the screen opened never reached it — the screen stayed blurhashed until it was dismissed and reopened.

The change

One byte cache both paths read: ProfilePictureCache, keyed by blob id, in the App Group container. A blob is immutable — a new picture is a new id — so a hit never needs revalidating, and the App Group placement means the notification extension can warm an avatar on push arrival that the app then draws at first paint. LRU-bounded at 24 MB, and cleared on logout beside NotificationPreviewCache: these are other people's photographs sitting unencrypted in a container the next account on the device reads from.

TipAvatarStore becomes ProfileAvatarStore under Core/Controllers/ — it serves every surface, not just tips — and tracks which blob each entry came from, so a changed picture reloads. TipConversationsScreen warms the whole list instead of letting each row start its download as it scrolls into view. RemoteImageLoader is deleted; its one consumer reads the byte cache directly.

RemoteImage / URLCache is untouched. It backs token and currency logos, whose URLs are stable and public, and is correct as it stands.

Notes

  • The extension-side warm is not in this PR. The App Group placement is what makes it possible later.
  • Android reached the same shape independently: ImmutableBlobCacheStrategy (cached blobs are never revalidated), MediaUrlResolver (re-mints an expired signed URL, and is skipped when the bytes are cached), ImageCachePresence.holds(cacheKey).

Generic over blobs, not over the fetch path

The cache is BlobCache, keyed by blob id, with one instance per kind of blob — BlobCache.profilePictures today. Chat images are blobs too, and everything that makes this work for avatars is true of them; the only avatar-specific parts were the subdirectory name and the budget, so the rename is cheaper now than after a second call site exists.

Each kind gets its own directory and its own ceiling rather than sharing a pool. An avatar thumbnail is tens of kilobytes and a full-size chat image is single-digit megabytes, so one shared 24 MB budget would let a scroll through an image-heavy conversation evict every avatar on the device — blurhashes again on the next launch, which is the bug this PR fixes. oneKindOverItsLimitDoesNotEvictAnother covers that.

The kinds sit under a common Blobs/ parent so clearAll() drops the lot on logout without a registry of kinds to keep in sync. A kind missing from such a registry would leave other people's photographs on disk through a logout, and nothing would surface the omission.

The fetch path is deliberately not generalized. BlobAccessContext has one case, .profile(UserID), so a chat image has nothing to authorize a download against yet — a generic fetcher today would be guessing at a proto that does not exist.

…y change

Profile pictures re-downloaded on every launch and, once downloaded, could
show the wrong person's photo until the process restarted. Four separate
causes, all in how the bytes were held:

`RemoteImageCache.install()` puts `.cacheMemoryOnly` into Kingfisher's
`defaultOptions`, so `RemoteImageLoader` — the signed-in user's picture —
never wrote to the disk cache and its `retrieveImage(forKey:)` could not hit
after a relaunch. `URLCache` cannot stand in for that here: `blobDownloadURL`
mints a fresh signed URL per fetch, so the same picture arrives under a new
key every time. Other users' pictures went through `TipAvatarStore`, which
held raw bytes in memory only and was gone on relaunch.

`TipAvatarStore.load` guarded on `dataByUser[userID] == nil`, so a user who
changed their picture — new blob id, same user id — never reloaded.
`ContactAvatarCache` had the same shape of bug one layer down: it keyed
decoded images by contact id and ignored the `data` argument on a hit, so the
old photo stayed on screen on every avatar surface in the app.
`UserProfileScreen` read the store at body-eval time and fed it into a
`@State` model built once, so a picture that arrived after the screen opened
never reached it.

The fix is one byte cache both paths read: `ProfilePictureCache`, keyed by
blob id in the App Group container. A blob is immutable, so a hit needs no
revalidation and the notification extension can warm an avatar the app then
draws at first paint. It is LRU-bounded at 24 MB and cleared on logout, next
to `NotificationPreviewCache` — these are other people's photographs sitting
unencrypted in a container the next account on the device reads from.

`TipAvatarStore` becomes `ProfileAvatarStore` under `Core/Controllers/`: it
serves every surface, not just tips, and it now tracks which blob each entry
came from so a changed picture reloads. `TipConversationsScreen` warms the
whole list rather than letting each row start its download when it scrolls
into view. `RemoteImageLoader` is deleted; its one consumer reads the byte
cache directly. `RemoteImage`/`URLCache` is untouched — it backs token and
currency logos, whose URLs are stable and public, and is correct as-is.
@bmc08gt bmc08gt self-assigned this Sep 11, 2026
Chat images are blobs too, and everything that makes this cache work for
avatars is true of them: keyed by blob id, immutable so a hit never needs
revalidating, LRU-bounded, in the App Group container. The only
avatar-specific parts were the subdirectory name and the budget. Renaming it
before a second call site exists costs nothing; renaming it after does.

Each kind gets its own directory and its own ceiling rather than sharing a
pool. An avatar thumbnail is tens of kilobytes and a full-size chat image is
single-digit megabytes, so one shared 24 MB budget would let a scroll through
an image-heavy conversation evict every avatar on the device — back to
blurhashes on the next launch, which is the bug this cache exists to fix.
`oneKindOverItsLimitDoesNotEvictAnother` covers it.

The kinds sit under a common `Blobs/` parent so `clearAll()` can drop the lot
on logout without a registry of kinds to keep in sync. A kind missing from
such a registry would leave other people's photographs on disk through a
logout with nothing to surface the omission.

The fetch path is deliberately left alone. `BlobAccessContext` has one case,
`.profile(UserID)`, so there is nothing to authorize a chat-image download
against yet; a generic fetcher today would be guessing at a proto that does
not exist.
@bmc08gt
bmc08gt merged commit 07c8b95 into main Sep 11, 2026
1 check passed
@bmc08gt
bmc08gt deleted the fix/avatar-image-cache branch September 15, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant