Skip to content

Commit bb01e0d

Browse files
committed
Implement iOS avatar persistent cache
1 parent 7a39a10 commit bb01e0d

19 files changed

Lines changed: 1931 additions & 61 deletions
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# iOS Agent/Team Avatar Persistent Cache
2+
3+
Task: `#TASK-1345`
4+
5+
This document is the final implementation design synthesized from design A,
6+
design B, and the task裁决. It is the durable source for the shipped iOS
7+
last-known-good avatar cache.
8+
9+
## Problem
10+
11+
`GaryxAgentAvatarView` currently renders a real avatar only when the current
12+
row has a usable `avatarDataUrl` and `GaryxDataURLImageCache` has a decoded
13+
`UIImage` in its volatile `NSCache`. When that cache is empty after relaunch,
14+
memory pressure, or eviction, the view immediately shows fallback content and
15+
does not self-invalidate when the async predecode later succeeds.
16+
17+
The second failure path is structural: thread, task, and widget rows often only
18+
know `agentId` or `teamId`. If the in-memory catalog does not currently contain
19+
that id, the projector passes no `avatarDataUrl`, so the row falls directly to
20+
the placeholder even if the app previously rendered a real avatar for that id.
21+
22+
The existing `GaryxMobileCatalogCache` persists catalog rows, including
23+
`avatarDataUrl`, but it is membership-bound and encoded as one large JSON blob.
24+
It is not an identity-keyed last-known-good image store, and it must not grow
25+
into one.
26+
27+
## Final Architecture
28+
29+
The cache has three one-way layers:
30+
31+
1. `GaryxMobileCatalogCache` remains the gateway-scoped catalog snapshot. It
32+
owns current catalog membership and current row fields.
33+
2. `GaryxAvatarStore` is the new membership-independent persistent fallback
34+
truth source. Its identity is gateway scope + kind (`agent` or `team`) + id.
35+
It stores raw decoded image bytes and a small index, never base64.
36+
3. `GaryxDataURLImageCache` stays a volatile decoded-image accelerator.
37+
38+
Catalog apply writes through to `GaryxAvatarStore`. Rendering reads live data,
39+
then persistent data, then placeholder. The render hot path never writes the
40+
persistent store and never performs disk I/O or base64/JSON work.
41+
42+
## Identity And Scope
43+
44+
Core defines:
45+
46+
- `GaryxAvatarKind`: `agent` or `team`
47+
- `GaryxAvatarIdentity`: `scope`, `kind`, `id`
48+
- `GaryxAvatarStoreEntry` and `GaryxAvatarStoreIndex`
49+
50+
The app supplies the same gateway scope token used by `scopedSettingsKey(_:)`
51+
and `currentGatewayScopeId`, so a shared agent id on two gateways cannot
52+
collide. Agent and team ids are separate namespaces.
53+
54+
Filesystem keys must be stable and safe for ids containing `/`, `:`, spaces,
55+
or other path-hostile characters. Hash the full storage key for file names.
56+
57+
## Core Policy
58+
59+
Core owns all cache strategy and is UIKit-free:
60+
61+
- Parse only `data:image/...;base64,...` candidates.
62+
- Reject empty strings, remote URLs, non-image media types, malformed base64,
63+
and per-record payloads larger than 512 KB.
64+
- Compute the content fingerprint from decoded bytes using FNV-1a, not from
65+
the raw data URL string.
66+
- Use an injected `GaryxAvatarImageValidating` protocol to decide whether live
67+
bytes are a real decodable image. App code implements it with image APIs;
68+
tests inject deterministic validators.
69+
- Resolve display priority as valid live -> stored -> placeholder.
70+
- Plan write-through batches as non-empty, valid, changed fingerprints only.
71+
- Never treat an ordinary empty or invalid refreshed avatar as a tombstone.
72+
- Delete only on explicit local delete or explicit clear-avatar intent after
73+
the gateway confirms the mutation.
74+
75+
Core also provides `GaryxInMemoryAvatarStore` for SwiftPM tests. It implements
76+
the same no-tombstone, fingerprint, LRU, scope isolation, explicit remove, and
77+
index round-trip rules without disk or UIKit.
78+
79+
## App Store
80+
81+
The app target implements `GaryxAvatarDiskStore` as an actor. It stores data in
82+
the App Group container `group.com.garyx.mobile`, under an avatar-cache
83+
subdirectory that is excluded from iCloud backup.
84+
85+
The store keeps:
86+
87+
- One raw-byte blob per avatar identity.
88+
- One small `index.json` with no base64.
89+
- Defaults of 256 records total, 16 MB total bytes, 512 KB per record.
90+
- LRU pruning by `lastAccessAt` only. It must never prune by current catalog
91+
membership because leaving the catalog is exactly when fallback is needed.
92+
93+
All parsing, validation, fingerprint comparison, JSON encoding, file I/O, and
94+
pruning run in the actor/off-main. Index writes are batched once per catalog
95+
apply, not once per entry.
96+
97+
## Write Path
98+
99+
There is one normal write path: catalog apply.
100+
101+
The existing `agents` and `teams` didSet hooks in `GaryxMobileModel.swift`
102+
already call `predecodeAgentAvatarImages()`. The new write-through runs from
103+
that same area: the main actor assembles lightweight candidates containing
104+
scope, id, kind, and the Swift `String` data-url reference, then hands them to
105+
the store actor. The actor computes fingerprints, validates bytes, diffs the
106+
index, writes changed blobs, and prunes.
107+
108+
Cold-start `restoreCachedCatalogState()` also feeds restored catalog rows once,
109+
so users get a first-run backfill from the pre-existing catalog snapshot.
110+
111+
Explicit mutation rules:
112+
113+
- Successful create/update with a valid avatar stores the new identity.
114+
- Successful update with an explicit empty avatar field removes the identity
115+
only because the local edit was an explicit clear intent.
116+
- Successful delete removes that identity.
117+
- Id changes do not silently migrate bytes. The new identity is stored from a
118+
valid response or explicitly cleared, then the old identity is removed.
119+
- Ordinary refresh rows with empty or invalid `avatarDataUrl` do not remove
120+
known-good bytes.
121+
122+
## Render Path
123+
124+
`GaryxAgentAvatarView` becomes a thin SwiftUI renderer:
125+
126+
- It builds a `GaryxAvatarIdentity` from environment scope + kind + id.
127+
- It asks an environment-injected singleton `GaryxAvatarImageProvider` for a
128+
synchronous `NSCache` hit.
129+
- On a miss it shows placeholder for that single view, then runs `.task(id:)`
130+
keyed by identity and live fingerprint.
131+
- The task resolves valid live bytes first, stored bytes second, placeholder
132+
last. On success it assigns local `@State resolvedImage`.
133+
- Filling one avatar must not publish list-wide observable state. The provider
134+
exposes no per-avatar `@Published` state.
135+
136+
When a fresh live avatar arrives, the task id changes and the view naturally
137+
promotes restored -> live. When identity changes, the previous local state is
138+
invalidated.
139+
140+
Remote `http(s)` avatar URLs keep the existing remote-image branch and are not
141+
persisted by this task.
142+
143+
## Presentation Helper
144+
145+
The view must not switch on provider kinds for fallback colors or icon sizes.
146+
`GaryxProviderPresentation` in Core exposes the data the view needs:
147+
148+
- fallback RGB background
149+
- foreground style hint
150+
- icon size factor
151+
- symbol/initials already provided today
152+
153+
The SwiftUI view maps Core data to `Color` and font size, with no local provider
154+
kind switch table.
155+
156+
## Widget
157+
158+
The widget receives the same last-known-good behavior without making Core call
159+
the disk actor:
160+
161+
- `GaryxRecentThreadsWidgetSnapshotProjector.widgetThreads(from:)` accepts
162+
`avatarFallback: [GaryxAvatarIdentity: String] = [:]`.
163+
- When a row has no current live avatar and the injected map has a fallback,
164+
the projector uses it. An empty map keeps current behavior and existing tests.
165+
- The app widget persistence actor pre-resolves missing identities from the
166+
avatar store off-main and passes the map into the pure projector.
167+
- New snapshots should avoid adding new large base64 fields. The existing
168+
`avatarDataUrl` field is kept for migration and current widget rendering, but
169+
no catalog snapshot schema changes are introduced.
170+
171+
## Rejected Alternatives
172+
173+
Do not extend `GaryxMobileCatalogCache` into a permanent avatar cache. Its
174+
membership lifetime conflicts with orphan-id fallback, and it rewrites one
175+
large JSON blob containing base64 data.
176+
177+
Do not persist `NSCache` or decoded `UIImage` values. Persistence is keyed by
178+
identity and raw decoded bytes; decoded images are rebuildable acceleration.
179+
180+
Do not decode or hit disk synchronously in SwiftUI body. The only synchronous
181+
render path is an in-memory cache lookup.
182+
183+
Do not add gateway/router/desktop behavior. This is an iOS local continuity
184+
optimization and must not invent a new product concept.
185+
186+
## Test Plan
187+
188+
Primary validation is headless `GaryxMobileCore` SwiftPM tests:
189+
190+
- Data URL parsing accepts a synthetic 1x1 PNG and rejects empty, malformed,
191+
non-image, remote URL, and oversize inputs.
192+
- FNV-1a fingerprints are stable across data-url whitespace/MIME casing and
193+
change when decoded bytes change.
194+
- Resolution returns live when live validates, stored when live is empty or
195+
invalid and a stored record exists, and placeholder only when neither exists.
196+
- Write-through writes only non-empty valid changed avatars.
197+
- Ordinary empty/invalid refresh rows do not tombstone records.
198+
- Explicit clear and successful delete remove records.
199+
- Id changes store or clear the new identity before removing the old one.
200+
- Agent/team kinds and gateway scopes do not collide.
201+
- LRU pruning obeys 256 records / 16 MB / 512 KB record bounds and bumps
202+
`lastAccessAt` on reads. It never prunes by catalog membership.
203+
- Index encode/decode round-trips and version mismatch is discarded.
204+
- Widget projection uses injected fallback for id-only rows and preserves
205+
existing nil behavior with an empty map.
206+
- Provider presentation exposes fallback styling data without view switch
207+
tables.
208+
209+
App validation must include `xcodegen generate` after adding app-target Swift
210+
files, followed by an app-target `xcodebuild` with code signing disabled if
211+
signing is the only blocker.

0 commit comments

Comments
 (0)