Every content image is an expo-image view with a box, a cache policy for its kind of source, a recycling key in recycled lists and a name (or a decorative mark); photos are picked or captured only from a press, through ports, the camera only after the permission gate; every picked file is downscaled and re-encoded once, copied into the app's media folder under a new name and its temporaries discarded; records hold that name (a MediaRef), never a file URI; files are never overwritten, swept when orphaned, and leave the device only through ports. A zero-dependency checker proves it on iOS and Android code alike.
Rules that must hold
- Every content image renders with expo-image inside a box. A
stylewith width and height (or one plusaspectRatio, or flex in a sized parent);contentFit, never React Native'sresizeMode,defaultSourceorfadeDuration. Why: without a box the view is 0 x 0 or jumps when the image loads, and the compatibility props are deprecated. (image-size,image-legacy-prop) - Every expo-image
<Image>is named or marked decorative.accessiblewithaccessibilityLabel={t(...)}for content,accessible={false}for decoration. Why: expo-image is not an accessibility element by default, so VoiceOver and TalkBack skip a photo silently. (image-a11y) - An image in a recycled list cell has a
recyclingKey(the row id). Why: FlashList reuses the cell and its image view shows the previous row's photo until the new one loads. (list-recycling-key) - The cache policy matches the source. Stored files
cachePolicy="memory"(they are on disk already); public remote images the default disk cache; private images memory-only or caches cleared on sign-out. Why: a disk cache duplicates stored files and keeps private photos after sign-out. - Pick and capture only from a press, through
MediaPickerPort: images only, no base64, no EXIF. Why: a picker opened from an effect surprises the person and reopens after a cancel; base64 puts megabytes on the JS heap; EXIF carries the GPS position. (picker-on-press,picker-options) - The camera opens only after the permission gate; only
src/services/permissions/asks. The library picker needs no permission on iOS 14+ and Android; the picker adapter reads the camera state through the gate's access (injected), never through its own permission functions. Why: iOS rejectslaunchCameraAsyncwithout the grant and Android shows the system dialog with no explanation; one gate handles denied, rationale and blocked on both. (permission-outside-gate) - The app config declares only the capture and library permissions the code uses.
cameraPermission: falsewhen nothing opens a camera,microphonePermission: falsewhen nothing records; these block the permission for the whole app, so never while expo-camera or expo-audio needs it. A save-only app keepsREAD_MEDIA_IMAGES/VIDEOout. Why: the defaults addRECORD_AUDIO, and Play restricts broad photo reads (its Photo and Video Permissions policy). (unused-permission) - Every picked or captured image is downscaled and re-encoded once (2048 px long edge, JPEG 0.8),
one at a time, before it is stored; image bytes never live in JS. Why: a 12 MP photo decodes to
49 MB; the re-encode drops EXIF and turns HEIC into JPEG; base64 data URIs cost memory twice and
defeat every cache. (
data-uri) - Kept media lives in the media store (
Paths.document/media/), never in the cache, and records hold a MediaRef (a plain file name), never a URI. Resolve withuriOf(ref)at render time. Why: the system purges the cache, and the absolute path of the app's folders changes after an update or a restore, so a storedfile://URI breaks. (media-in-cache,stored-uri) - A changed image is a new file under a new name; nothing is overwritten. Write the partial file,
rename it into place, save the record, then remove the old file. Why: image caches are keyed by URI
and keep showing old pixels, and a crash mid-write corrupts the only copy. (
overwrite-in-place) - Every stored file has one owner record, and an orphan sweep runs after startup and after a
restore. Save the record first, then delete the file; the sweep keeps every file this process
imported and every file the restore's safety copy references (
protectedRefs), and removes stale partial copies. Why: crashes and abandoned imports leave files no record references, and nothing else removes them; the undo of a restore needs its photos. (sweep-wired) - Only current SDK 57 APIs. expo-file-system's
File/Directory/Pathsand expo-media-library's object API; never their legacy main-entry functions or/legacyentries. Why: in SDK 57 the old functions compile (declared@deprecated) and throw at runtime;documentDirectoryandcacheDirectoryare no longer exported (a tsc error, orundefinedin untyped code). (legacy-api) - Images leave the device only through ports, from a press: the share sheet (data-export-and-
restore's
ShareSheetPort),PhotoSaverPort, andFileTransferPortfor uploads and downloads, with URLs fromsrc/apiand headers from the session. Why: one adapter per SDK, the app's network policy keeps its single entry per transport, and only re-encoded, metadata-free files leave. (transfer-outside-port)
Workflow
- Read references/displaying-images.md for any image on screen, and references/picking-and-capturing.md plus references/files-and-storage.md when the person adds photos.
- Prerequisites: the Result module (error-handling-typed-results) and the composition root
(architecture-and-boundaries). Install:
npx expo install expo-image-picker expo-image-manipulator expo-file-system expo-device expo-crypto(SDK 57:~57.0.20,~57.0.20,~57.0.7,~57.0.2,~57.0.3;expo-image ~57.0.5ships with the template, expo-file-system often arrives with persistence-and-migrations; verified 2026-10-14; re-verify withnpx expo install --check). - Stop and ask the developer to classify the new SDKs (an architecture decision): merge
examples/contracts/architecture.jsonintocontracts/architecture.jsonand add the packages toVENDOR_SDK_PATHSineslint.config.mjsin one commit with a Gate-Change trailer. - Copy the templates:
templates/src/core/media/,templates/src/services/{media-picker,media-store,image-resizer}/,templates/src/data/media/,templates/src/ui/media/(listStoredImagein the component catalogue, ui-component-library). Copy the resizer adapter fromexamples/src/services/image-resizer/once expo-image-manipulator is installed. - Set the picker plugin options in
app.config.ts(picking-and-capturing.md, section 8): purpose texts from the catalogs (i18n-strings-and-catalogs),falsefor every permission no code uses (they block it for the whole app: check expo-camera and expo-audio first). - Compose once: the three adapters in
createDeviceServices(fakes increateTestServices), the picker withisCameraAllowed: async () => isPermissionAllowed(await permissions.access.camera.permission())andisCameraPresent: () => Platform.OS !== 'ios' || Device.isDevice(expo-device; opening the camera on the iOS simulator crashes, picking-and-capturing.md section 5), andcreateMediaImporter({ picker, resizer, store, nextMediaId }); features readuseServices(). In the startup work after the data layer opened (app-startup-and-bootstrap),await importer.importPendingPick()first, thensweepMedia(...)with the safety copy's refs asprotectedRefs(files-and-storage.md section 9); sweep again after a restore.nextMediaIdis() => randomUUID().toLowerCase()from expo-crypto (Hermes has no globalcrypto.randomUUID). - Store MediaRefs: a
*_ref TEXTcolumn through a migration (persistence-and-migrations) or aMediaReffield in a store; a repository method lists every ref for the sweep. - Build the feature from
examples/src/features/tasks/(model hook, jobs, photo strip): "Take photo" calls permissions-and-system-prompts'usePermissionGaterequestAccessand the capture runs from itsonAllowed, the screen renders the gate's sheet; one message per failure kind, the busy state blocking a second press, a failed record save removing the new files, remove = save then delete. - Sharing, Photos, uploads: references/sharing-and-transfer.md
and
examples/src/services/{photo-saver,file-transfer}/; tell networking-and-server-state and privacy-and-store-declarations about a new transport or data type before it ships. - Run the tests (references/testing-and-checker.md):
npx jest src/core/media src/services/media-picker src/services/media-store src/services/image-resizer src/data/media src/ui/media --ciandnpm run typecheck. - After an adapter change, run the media drill on a named iOS simulator and Android emulator (testing-and-checker.md, section 3), or say in the report that it is owed.
- Run
node ${CLAUDE_SKILL_DIR}/scripts/check-media.mjs .; fix everyFAILline and rerun until it printsRESULT: PASS.
Definition of done
- Every expo-image
<Image>has a box,contentFit(no legacy props), a name oraccessible={false}, a cache policy fit for its source, and arecyclingKeyin recycled cells. - Picks and captures run from presses through
MediaPickerPort; images only, no base64, no EXIF; the camera behind the permission gate; no plugin declares an unused capture or library permission. - Every picked image goes through the importer: downscaled to 2048 px, JPEG, one at a time, stored
under a new MediaRef in
Paths.document/media/, temporaries discarded, all or nothing per batch. - Records and stores hold MediaRefs, resolved with
uriOfat render; nothing overwrites a stored file; deletes save the record first; the sweep runs after startup and after a restore and keeps the safety copy's refs (protectedRefs). - No legacy expo-file-system or expo-media-library API; uploads and downloads only in the
file-transfer adapter; sharing through
ShareSheetPort. - The tests of workflow step 10 pass; after an adapter change the drill passed on iOS and Android, or the report says it is owed.
-
node ${CLAUDE_SKILL_DIR}/scripts/check-media.mjs .printsRESULT: PASS. Expected only before the app config exists:SKIP app.config.ts [unused-permission] not yet created (app-variants-and-environment creates it). An app without media code printsNOT APPLICABLE: no source file under src/ imports expo-image, expo-image-picker, expo-camera, expo-image-manipulator, expo-media-library, expo-file-system, and there is no src/services/media-store/.
Anti-patterns
- Saving
asset.urifrom the picker in a record. It is a cache file the system purges, at an absolute path that changes; import it and store the MediaRef. base64: true"to upload it easily", or adata:imageURI. Megabytes on the JS heap, decoded twice, no cache; upload the stored file withFileTransferPort.- Opening the picker in
useEffectwhen a screen mounts. It reopens after a cancel and on every focus; open it from the press. - Calling
requestCameraPermissionsAsyncin a screen. No explanation, no blocked state; the gate. cameraPermission: falseon the picker in an app with an expo-camera scanner. The option stripsCAMERAfrom the merged manifest for every package; the scanner can never be granted.granularPermissionsleft at the default in a save-only app. It declaresREAD_MEDIA_IMAGESandREAD_MEDIA_VIDEO, which Play reviews under its Photo and Video Permissions policy.- Overwriting
photo.jpgafter a crop. Every view keeps the old image; a new ref per change. - Deleting the file, then saving the record. A crash leaves a record pointing at nothing; record first, the sweep removes leftovers.
- Shipping full-size camera photos. 5 to 10 MB per file, 49 MB per decode, Android's 25 MB backup quota gone after a few photos; downscale once on import.
- Sharing the picked original. It carries the GPS position; share the stored, re-encoded file.
allowDownscaling={false}orcontentFit="none"in a list. Full-size decodes per cell.saveToLibraryAsyncorreadAsStringAsyncfrom the main entries. They throw in SDK 57; use the object APIs.- A second expo-sharing adapter. Reuse data-export-and-restore's
ShareSheetPort.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/displaying-images.md | expo-image API, sizing, cache policies, recycling, memory arithmetic, names, remote images, placeholders, platform differences, pitfalls | Workflow step 1, any image on screen |
| references/picking-and-capturing.md | Picker and camera API, permissions per platform (read in the native sources), the gate, Android's lost activity, returned files, app config, expo-camera | Workflow steps 1-3, 5, 8 |
| references/files-and-storage.md | The folders, MediaRef, the import, no overwrite, owners and the sweep, disk space and backups, the SDK 57 file-system API and its throwing legacy names, backups and media | Workflow steps 1, 6-7 |
| references/sharing-and-transfer.md | Share sheet, saving to Photos (expo-media-library 57), the file-transfer port, privacy, receiving images | Workflow step 9 |
| references/testing-and-checker.md | The shipped tests, testing with fakes, the media drill on named devices, every check-media rule with its fix, configuration | Workflow steps 10-12 |
templates/src/core/media/ |
media-ref.ts (MediaRef, extensions), image-fit.ts (fit, decoded bytes, MEDIA_LIMITS), media-sweep.ts (the sweep plan), each with its test |
Workflow step 4 |
templates/src/services/ |
media-picker/, media-store/, image-resizer/: ports, the picker and file-system adapters, fakes with tests |
Workflow steps 4, 6 |
templates/src/data/media/ |
media-importer.ts (pick, downscale, store, discard; all or nothing; protected from the sweep from the moment each file is stored until the process ends) and media-sweeper.ts (stale partials, orphans), with tests |
Workflow steps 4, 6 |
templates/src/ui/media/ |
stored-image.tsx (sized, memory-cached, recycled, named) and its test |
Workflow step 4 |
examples/src/services/ |
image-resizer/ (expo-image-manipulator adapter), photo-saver/ (expo-media-library port and adapter), file-transfer/ (upload and download port and adapter), type-checked against their SDK 57 packages |
Workflow steps 4, 9 |
examples/src/features/tasks/ |
A task's photos: model hook (the camera through usePermissionGate) and its test (allowed, refused, rationale, blocked, failed save), press jobs (import, save, remove, share), the photo strip (a FlatList: the list is bounded) |
Workflow step 8 |
examples/contracts/architecture.json |
The vendor SDK entries to merge | Workflow step 3 |
examples/task-photos-messages.en.json |
The example's catalog keys | Workflow step 8 |
scripts/check-media.mjs |
The checker (15 rules; --json) |
Workflow step 12, and at the end |
scripts/lib/media-rules.mjs |
The checker's rules | Never by hand |
scripts/lib/source-scan.mjs |
Shared source scanner, synced from the library (do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves the checker passes the templates and examples and catches every planted bug | After changing a script, a template or an example |
assets/shared.json |
The shared files this skill copies in | When adding a shared file |
assets/template-placeholders.json |
Template placeholder values (none) | When adding a placeholder |
tests/ |
The fixture app (bases/app) and the overlay cases of check-media (one planted bug per rule) |
When adding a rule |
Related skills
icons-app-icon-and-splash- icons, the logo and illustrations drawn in code; bundled art.permissions-and-system-prompts- the camera and photo-library permission gate.data-export-and-restore-ShareSheetPort, backups (media refs vs files).architecture-and-boundaries- ports, adapters, SDK classification, the composition root.persistence-and-migrations- the MediaRef columns and their migrations.networking-and-server-state- the ApiClient beside the file-transfer transport.privacy-and-store-declarations- Photos or Videos in the store answers, the network audit.performance-budgets- the memory budget and how to measure it on devices.accessibility-as-code- image names, Reduce Motion for transitions.react-components-and-hooks- lists, effects,useIsAppActivefor a camera view.expo-prebuild-and-config-plugins,i18n-strings-and-catalogs- plugin listing and purpose texts.auth-and-secure-storage- headers for private images, clearing caches on sign-out.unit-and-component-tests,e2e-maestro- test conventions and device flows.ios-simulator-build,android-emulator-build- the builds the drill installs.