Repository navigation
fix(avatars): cache profile pictures on disk and reload them when they change - #760
Merged
Merged
Conversation
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.cacheMemoryOnlyinto Kingfisher'sdefaultOptions, soRemoteImageLoader— the signed-in user's own picture — never wrote to the disk cache, and itsretrieveImage(forKey:)could not hit after a relaunch.URLCachecannot stand in for it here:blobDownloadURLmints a fresh signed URL per fetch, so the same picture arrives under a different key every time. Other users' pictures went throughTipAvatarStore, which held raw bytes in memory only and was gone on relaunch.A changed picture never replaced the old one.
TipAvatarStore.loadguarded ondataByUser[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.ContactAvatarCachehad the same shape of bug one layer down — it keyed decoded images by contact id and ignored itsdataargument 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.UserProfileScreencaptured the bytes once. It read the store at body-eval time and fed the result into a@Statemodel 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 besideNotificationPreviewCache: these are other people's photographs sitting unencrypted in a container the next account on the device reads from.TipAvatarStorebecomesProfileAvatarStoreunderCore/Controllers/— it serves every surface, not just tips — and tracks which blob each entry came from, so a changed picture reloads.TipConversationsScreenwarms the whole list instead of letting each row start its download as it scrolls into view.RemoteImageLoaderis deleted; its one consumer reads the byte cache directly.RemoteImage/URLCacheis untouched. It backs token and currency logos, whose URLs are stable and public, and is correct as it stands.Notes
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.profilePicturestoday. 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.
oneKindOverItsLimitDoesNotEvictAnothercovers that.The kinds sit under a common
Blobs/parent soclearAll()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.
BlobAccessContexthas 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.