إنتقل إلى المحتوى الرئيسي

14 · Media & field capture

Purpose: Specify @rasd/media — the capture adapters (geolocation, camera/photo, barcode, signature, audio, video, file) that the renderers call, their web and React Native implementations, the photo pipeline, defaults and budgets, and how captured bytes become AttachmentRefs that storage and sync carry. Audience: Engineers building @rasd/media, @rasd/react, @rasd/native; host developers who wire adapters into <RasdProvider media={…}> or replace them.

TL;DR

  • @rasd/media is storage-agnostic and UI-agnostic: it turns a user gesture into a CapturedFile (bytes + sidecar metadata) or a GeoFix; the renderer stores it through StorageAdapter.attachments and submissions.patch in one transaction; @rasd/sync uploads it later via tus. Fields never touch browser or Expo APIs directly.
  • Every capability is an interface with lazy, code-split platform implementations (@rasd/media/<capability>) and a fake in @rasd/testing. A missing adapter or a denied permission degrades to a manual fallback (allowManual, gallery, <input type="file">) — never a crash, never a blocked optional question.
  • Photo defaults follow ODK Collect: long edge 1280 px, JPEG q 0.7 (≈ 150–350 KB), EXIF stripped, capturedAt + optional geo kept as a sidecar on the AttachmentRef, orientation normalised before stripping (research §4.2).
  • Geopoint UX: live accuracy readout, auto-accept at ≤ accuracyThreshold (5 m), non-blocking warning > warningThreshold (100 m), 60 s timeout → accept-best / retry / manual, map placement on MapLibre with offline PMTiles, Android mock-provider flag.
  • Barcode: BarcodeDetector when present (~76 % availability), otherwise the barcode-detector 3.2.1 ponyfill with self-hosted ZXing WASM so scanning works offline and under strict CSP; expo-camera on native; RFD formats use BarcodeDetector names — the spine's short spellings ("qr", "code128", spine §4.3) are accepted as aliases and normalised at load — and are mapped for Expo.
  • Signature is a stroke model rendered to a trimmed opaque PNG (typically 10–30 KB, hard cap 256 KiB) on a canvas (web) or RNGH + react-native-svg rasterised by react-native-view-shot (native, no WebView).
  • Audio defaults to mono ~32 kbps AAC/Opus (≈ 14 MB/h), 600 s max; video is never transcoded on device and defaults to 25 MB / 120 s; files pass an allow-list plus magic-byte sniff. Per-submission attachment budget defaults to 10 MB (warn at 80 %, W_ATTACHMENT_BUDGET at finalize). Every size ceiling in this document is a restatement of the normative table in 00 §7.1.
  • Privacy defaults: nothing is written to the gallery/Photos, EXIF GPS never leaves the device unless geotag: true, thumbnails and blobs are encrypted at rest with the rest of storage, object URLs are revoked on unmount.

1. Scope, package layout and budgets

@rasd/media (spine §3) depends only on @rasd/core; platform SDKs are optional peers resolved lazily on first use.

Entry pointWeb implementation (peer)Native implementation (peer)Lazy chunk budget (min+gz)
@rasd/media root → web / native bundle, createMediaAdapters()≤ 4 kB (interfaces, permission helpers, lazy loaders)
@rasd/media/geolocationnavigator.geolocationexpo-location≤ 3 kB
@rasd/media/camera (+ image pipeline)<input capture> / getUserMedia, browser-image-compression 2.0.2expo-camera, expo-image-picker, expo-image-manipulator≤ 12 kB (+ compression worker ≤ 20 kB)
@rasd/media/barcodeBarcodeDetectorbarcode-detector 3.2.1 (zxing-wasm 3.1.1, self-hosted .wasm ≈ 1 MB, loaded on demand)expo-camera CameraView≤ 8 kB JS
@rasd/media/signatureCanvas 2DRNGH 3 + react-native-svg + react-native-view-shot (optional @shopify/react-native-skia)≤ 5 kB
@rasd/media/audio, /videoMediaRecorderexpo-audio, expo-image-picker (video)≤ 5 kB each
@rasd/media/file<input type="file">expo-document-picker≤ 2 kB
@rasd/media/mapsmaplibre-gl + pmtiles@maplibre/maplibre-react-native 11.3.6 (dev build)host-installed peers; not counted in the form-runner budget

The root export resolves by the react-native exports condition: import { web as media } from '@rasd/media' on web (as in 06), import { native as media } from '@rasd/media' on RN. Both are MediaAdapters objects whose members are lazy proxies; nothing platform-specific executes until a capture field mounts. Renderers pass the object via <RasdProvider media={media}>; hosts may override individual members with createMediaAdapters({ ...web, camera: myCamera, policy: { image: { maxPixels: 1600 } } }).

Named exports (17 §9 is the index): web, native, createMediaAdapters(overrides?), permissionFor(kind) and compressImage(input, { maxPixels, quality, keepExif? }) from the root; one factory pair per capability entry — createWebGeolocation / createExpoLocation, createWebCamera / createExpoCamera, createWebBarcode({ wasmUrl }) / createExpoBarcode, createCanvasSignature / createSvgSignature, createWebAudio / createExpoAudio, createWebFilePicker / createExpoDocumentPicker, createMaplibreMap. Every factory returns the matching interface from §2, so a host can mix platforms (Expo Go without a dev build, react-native-web) without forking the renderer.

2. Common types and adapter interfaces

The interfaces below are the normative surface (a superset of the sketch in 03 §8). All I/O accepts an AbortSignal, adapters throw RasdError (never raw driver errors, cause preserved), and every adapter has dispose().

import type { Geo, AttachmentRef, LocalizedString, RasdError } from '@rasd/core';

export interface CapturedFile {
blob?: Blob; // web
uri?: string; // native file:// URI (app-private, temporary until stored)
mime: string; bytes: number; name?: string;
width?: number; height?: number; durationMs?: number;
capturedAt: string; // ISO-8601 UTC, device clock
geo?: Geo; // sidecar geotag when requested
origin: 'camera' | 'gallery' | 'viewfinder' | 'recorder' | 'picker' | 'pad' | 'unknown';
ext?: Record<string, unknown>;
}
export interface GeoFix extends Geo { heading?: number; speed?: number; altitudeAccuracy?: number; mocked?: boolean; provider?: string }

export type PermissionState = 'granted' | 'denied' | 'blocked' | 'prompt' | 'unavailable';
export interface PermissionAware {
permission(): Promise<PermissionState>;
requestPermission(): Promise<PermissionState>; // MUST be called from a user gesture
}

export interface GeolocationAdapter extends PermissionAware {
readonly capabilities: { highAccuracy: boolean; mockedFlag: boolean };
getCurrent(opts: { highAccuracy: boolean; timeoutMs: number; maximumAgeMs?: number }, signal?: AbortSignal): Promise<GeoFix>;
watch(opts: { highAccuracy: boolean; timeoutMs: number; minIntervalMs?: number; minMeters?: number },
cb: (fix: GeoFix) => void, onError?: (e: RasdError) => void): () => void; // returns unsubscribe
dispose(): void;
}
export interface CameraAdapter extends PermissionAware {
readonly capabilities: { viewfinder: boolean; strictCamera: boolean; multiple: boolean };
capture(opts: { source: 'camera' | 'gallery' | 'both'; maxPixels?: number; quality?: number; geotag?: boolean;
multiple?: boolean; maxCount?: number; facing?: 'environment' | 'user'; annotate?: boolean;
watermark?: WatermarkSpec }, signal?: AbortSignal): Promise<CapturedFile[] | null>; // null = cancelled
dispose(): void;
}
export interface BarcodeAdapter extends PermissionAware {
readonly available: boolean; // some engine (native API or ponyfill) is usable
supportedFormats(): Promise<string[]>; // BarcodeDetector names
scan(opts: { formats: string[]; multiple?: boolean; torch?: boolean }, signal: AbortSignal): Promise<string | string[] | null>;
scanImage(file: Blob | string, formats: string[]): Promise<string[]>; // decode from a picture (fallback)
dispose(): void;
}
export interface Stroke { points: { x: number; y: number; t: number; p?: number }[]; color: string; width: number }
export interface SignatureAdapter {
toPng(strokes: Stroke[], opts: { width: number; height: number; scale?: number; background?: string | 'transparent';
trim?: boolean; padding?: number; maxBytes?: number }): Promise<CapturedFile>;
dispose(): void;
}
export interface AudioAdapter extends PermissionAware {
record(opts: { maxDurationSeconds: number; quality?: 'voice' | 'standard' | 'high';
onTick?: (s: { elapsedMs: number; estimatedBytes: number; level?: number }) => void },
signal: AbortSignal): Promise<CapturedFile | null>;
dispose(): void;
}
export interface VideoAdapter extends PermissionAware {
record(opts: { maxDurationSeconds: number; maxBytes: number; preset?: '480p' | '720p' | '1080p'; source?: 'camera' | 'gallery' | 'both' },
signal: AbortSignal): Promise<CapturedFile | null>;
dispose(): void;
}
export interface FilePickerAdapter {
pick(opts: { accept: string[]; maxBytes: number; multiple: boolean; maxCount?: number }, signal?: AbortSignal): Promise<CapturedFile[]>;
dispose(): void;
}
export interface MapAdapter { // usage and offline basemaps: §4.4
readonly available: boolean;
render(container: unknown, // HTMLElement on web, a native view ref on RN
opts: { center?: { lat: number; lng: number }; zoom?: number; marker?: Geo | null; path?: Geo[];
basemap?: string; interactive?: boolean;
onPlace?: (p: { lat: number; lng: number }) => void;
onVertex?: (p: { lat: number; lng: number }) => void }): Promise<MapHandle>;
dispose(): void;
}
export interface MapHandle {
setMarker(g: Geo | null): void; setPath(points: Geo[]): void;
flyTo(c: { lat: number; lng: number }, zoom?: number): void; destroy(): void;
}
export interface MediaAdapters {
geolocation?: GeolocationAdapter; camera?: CameraAdapter; barcode?: BarcodeAdapter; signature?: SignatureAdapter;
audio?: AudioAdapter; video?: VideoAdapter; file?: FilePickerAdapter; maps?: MapAdapter;
policy?: MediaPolicy;
}
export interface MediaPolicy { // host-level defaults; element props win where both exist
image: { maxPixels: number /* 1280 */; quality: number /* 0.7 */; keepExif: boolean /* false */; thumbnailPx: number /* 240 */;
watermark?: WatermarkSpec; strictCamera: boolean /* false */ };
audio: { quality: 'voice' | 'standard' | 'high' /* 'standard' */ };
barcode: { wasmUrl?: string /* self-hosted zxing-wasm URL on the customer origin; §6 */ };
file: { defaultAccept: string[]; deny: string[] /* ['image/svg+xml','text/html','application/x-*executable*', …] */ };
submissionBudgetBytes: number /* 10_000_000 */;
gallerySave: boolean /* false — never copy to MediaStore/Photos */;
}
export interface WatermarkSpec { timestamp?: boolean; geo?: boolean; text?: LocalizedString; position?: 'bottom-start' | 'bottom-end' }

Error codes used by this package (17 §16 is the consolidated table): RASD_MEDIA_PERMISSION (details.reason: 'denied' | 'blocked' | 'unavailable', retryable when askable), RASD_MEDIA_UNAVAILABLE (no hardware/API and no fallback), RASD_MEDIA_LIMIT (maxBytes, maxDurationSeconds, maxCount or the submission budget exceeded — details: { limit, actual, kind }), RASD_MEDIA_TYPE (MIME not allowed or sniff mismatch — details: { mime }); storage-side failures surface as RASD_ATTACHMENT_FAILED / RASD_STORAGE_QUOTA (03 §10). Two non-error outcomes are distinct and must not be conflated: user cancellation resolves null (or [] for pick), while an aborted AbortSignal rejects with RASD_ABORTED (retryable), so a field that unmounts mid-capture can be told apart from an enumerator who tapped "Cancel".

3. Permissions and denied-permission UX

Rules shared by web and native (07 §10 has the state diagram):

  1. Request on first use of a question, from the user's tap, never at app start; show a one-sentence localized rationale first (Play/App Store guidance, research/11 §11).
  2. Distinguish denied (askable again — inline hint + retry) from blocked (native: "Open settings" via Linking.openSettings(); web: instructions for the browser's site-permission UI, since no API can reopen the prompt).
  3. Denied or unavailable ⇒ the question stays answerable: geopoint/barcode via allowManual, image via gallery when source allows or <input type="file">, audio/video via file pick when the host enables it. A required capture question whose only path is blocked shows the error at finalize with the fallback offer — it never traps the enumerator mid-form.
  4. Emit RasdError RASD_MEDIA_PERMISSION to the host (onError) once per (question, session) and an audit event permission_denied (field, capability, reason).
  5. Foreground only: no ACCESS_BACKGROUND_LOCATION, no background audio, no READ_MEDIA_IMAGES (system Photo Picker instead). The Expo config plugin writes usage strings from permissions/usageDescriptions (07 §16); bare RN hosts add them by hand (07 §17).

Web specifics: geolocation, camera, microphone and BarcodeDetector need a secure context; embedded frames need allow="camera; microphone; geolocation" and the page a matching Permissions-Policy. navigator.permissions.query({ name }) is used only as a hint (camera/microphone names are missing in Firefox and older Safari); the truth is the API call. iOS Safari may re-prompt geolocation per session; treat prompt as normal. In Android WebViews (an embedding mode listed in 11 §11) the host app must forward onPermissionRequest and onGeolocationPermissionsShowPrompt to the OS prompt itself; a WebView that ignores them reports denied forever, so the adapters treat a permission call that resolves without any OS prompt inside 200 ms as blocked and go straight to the fallback.

4. Geolocation

4.1 Adapters

  • Web: navigator.geolocation.watchPosition(ok, err, { enableHighAccuracy: true, timeout: opts.timeoutMs, maximumAge: 0 }); GeolocationPositionError codes map to denied (1), unavailable (2), timeout (3, retryable). watch() clears the watch on unsubscribe and when the document becomes hidden for > 60 s (mobile browsers stop delivering fixes anyway). No mock flag exists on web (capabilities.mockedFlag = false).
  • Native: expo-location requestForegroundPermissionsAsync(), watchPositionAsync({ accuracy: Accuracy.Highest, timeInterval: minIntervalMs ?? 1000, distanceInterval: minMeters ?? 0 }) → subscription remove(); LocationObject.coords.{accuracy, altitude, altitudeAccuracy, heading, speed} and Android mocked are copied to GeoFix (research §4.1: Accuracy.High ≈ 10 m, Balanced ≈ 100 m). mayShowUserSettingsDialog: true lets Android offer to enable location services; on iOS enableNetworkProviderAsync is a no-op.

4.2 Accuracy-convergence UX (geopoint)

stateDiagram-v2
[*] --> Idle
Idle --> Acquiring: Capture tapped or autoCapture on reveal
Acquiring --> Acquiring: fix received — keep best, update live readout
Acquiring --> Accepted: best accuracy ≤ accuracyThreshold
Acquiring --> TimedOut: 60 s without an acceptable fix
TimedOut --> Accepted: user accepts best — warning above warningThreshold
TimedOut --> Acquiring: retry
Acquiring --> Denied: permission denied or unavailable
Idle --> Manual: allowManual — map or typed coordinates
TimedOut --> Manual
Denied --> Manual
Manual --> Accepted
Accepted --> Idle: clear or recapture

Algorithm (per fix): ignore fixes older than 10 s or without accuracy; keep the fix with the smallest accuracy; render "± N m" in a throttled live region (one announcement per 2 s, aria-live="polite"); auto-accept when best ≤ accuracyThreshold (default 5 m; 0 = manual accept only, ODK capture-accuracy semantics); after 60 s offer Use best (± N m) / Keep trying / Place on map; accepting above warningThreshold (100 m) stores the value and raises a non-blocking FieldIssue with severity: "warning" plus the audit event constraint warning (17 §2). Stored value is Geo (lat, lng, alt?, accuracy?, capturedAt); mocked: true writes submission.meta.ext["dev.rasd.geo"].mocked = true and shows a persistent warning (04 §10.11). Manual override: typed decimal degrees (LTR island, validated lat ∈ [-90,90], lng ∈ [-180,180]) or map long-press; manual values carry accuracy: null and ext["dev.rasd.geo"].manual = true on the submission so analysts can filter them. Battery: the watch runs only while the question is visible; autoCapture starts it on reveal and stops it on accept.

4.3 Geotrace / geoshape

mode: "manual" = Add point here (current best fix, same acceptance rules per vertex) or tap-to-place on the map; mode: "auto" = a vertex every intervalSeconds (10) only if accuracy ≤ accuracyThreshold and distance from the previous vertex ≥ 2 m (jitter filter). Builder hint: raise accuracyThreshold to 10–20 m for auto mode. Controls: undo last vertex, live length (distance(), haversine) or area (area(), spherical excess) from @rasd/core, minimum points (2 trace / 3 unique for shape), Close shape appends the first vertex so geoshape is a closed ring (first == last, ≥ 4 entries). Auto mode is foreground-only: request navigator.wakeLock.request('screen') (Chrome 84+, Safari 16.4+) / expo-keep-awake when installed, and pause with a banner when the app is backgrounded (no background-location permission is ever requested).

4.4 Map picker and offline basemaps

MapAdapter (@rasd/media/maps, §2) is the whole map surface the renderer sees: render(container, { center, zoom, marker, path, onPlace, onVertex })MapHandle. The map is optional everywhere — props.map: true is a request, not a hard dependency.

Web. MapLibre GL JS with the pmtiles protocol (maplibregl.addProtocol('pmtiles', protocol.tile), registered once at init) over PMTiles v3 regional extracts (research §4.1). createPmtilesBasemap({ name, url, storage }) downloads the archive once with resumable HTTP range requests and serves tiles through a pmtiles Source whose getBytes(offset, length) reads the stored bytes back. A basemap is not an attachment: it belongs to no submission, must never enter the outbox, and a governorate-level vector extract (20–200 MB) far exceeds the 25 MB per-blob ceiling BlobStore.put enforces (09 §4.2). It is therefore written as fixed 8 MiB parts under the reserved ids basemap:<name>#<n> with a manifest row in storage.kv (rasd.basemap.<name>{ bytes, parts, etag, downloadedAt }), and getBytes stitches the two or three parts a range touches. Style JSON, sprites and glyph ranges live in the rasd-media-v1 runtime cache (11 §3). Both reservations this needs are granted: attachments.gc() never deletes an id prefixed basemap: (00 §7, 09 §3) and sync phase 2 skips attachments whose submissionId is the $asset sentinel (10 §4.3).

Native. @maplibre/maplibre-react-native 11.3.6 (RN ≥ 0.80, Expo ≥ 54, development build) with OfflineManager packs; the archive is a single file, rasd/<ns>/basemaps/<name>.pmtiles, read with expo-file-system byte ranges. PMTiles-on-native is only recommended after verifying pmtiles:// support in the installed MapLibre Native version.

Budget and degradation. Basemaps are downloaded through an explicit "Download map" action on Wi-Fi — never automatically and never on a metered link. Without a map peer or a downloaded basemap the picker degrades to typed decimal degrees plus an "Open in maps app" link (geo:lat,lng), and MapAdapter.available is false.

5. Photo capture

5.1 Sources and the camera-only policy

props.sourceWebNativeProvenance recorded (origin)
camera (default)<input type="file" accept="image/*" capture="environment"> (opens the camera on iOS Safari/Android Chrome; a hint on desktop) or, when policy.image.strictCamera, the in-app viewfinder (getUserMedia({ video: { facingMode: { ideal: 'environment' } } })ImageCapture.takePhoto() or canvas grab)expo-camera CameraView.takePictureAsync({ quality: 1, exif: false, skipProcessing: false }) (strict) — the system camera via expo-image-picker.launchCameraAsync is opt-incamera / viewfinder
gallery<input type="file" accept="image/*"> (no capture)expo-image-picker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsMultipleSelection, selectionLimit }) — Android system Photo Picker, no READ_MEDIA_IMAGESgallery
bothTwo buttons; <input capture> cannot be enforced, so origin is best-effort on webBoth buttonsas chosen

Anti-fraud designs (fresh photo required) use source: "camera"; on web only strictCamera (viewfinder) proves provenance, and the renderer says so in the builder inspector. capture() returns null on cancel, [] never.

5.2 Pipeline

flowchart TD
A["Source: camera, gallery or viewfinder"] --> B["Decode and apply EXIF orientation"]
B --> C{"long edge above maxPixels?"}
C -->|yes| D["Downscale to maxPixels in a worker"]
C -->|no| E["Optional annotate and watermark"]
D --> E
E --> F["Encode JPEG at quality q, metadata dropped"]
F --> G{"bytes within maxBytes?"}
G -->|no| H["Retry at q minus 0.1 down to 0.5, then RASD_MEDIA_LIMIT"]
G -->|yes| I["Thumbnail 240 px"]
I --> J["attachments.put returns AttachmentRow with sha256 and bytes"]
J --> K["AttachmentRef: attachmentId, mime, bytes, sha256, capturedAt, geo?"]
K --> L["submissions.patch and engine.setValue in one transaction"]

Defaults and budgets: maxPixels 1280 (long edge, proportional), quality 0.7 → 150–350 KB for a 12 MP source; maxBytes 5 MiB (04 §10.13); thumbnails 240 px JPEG q 0.6 (~15 KB) are stored as a separate attachment row with field: '<name>#thumb' on web and as thumbs/<attachmentId>.jpg on native (09 §4.2, 07 §9) so lists never decrypt or decode a full photo; thumbnails are never uploaded. capturedAt is the device clock at shutter time (ISO UTC); geo is filled only when props.geotag is true, from the geolocation adapter (best fix ≤ 60 s old, otherwise a fresh getCurrent with 10 s timeout, otherwise omitted with a warning) — never from EXIF, so behaviour is identical for camera and gallery and for iOS (whose camera returns no GPS EXIF).

EXIF policy. Stored JPEGs carry no metadata: re-encoding through canvas/expo-image-manipulator drops APP1 wholesale. Orientation is applied before stripping (createImageBitmap(blob, { imageOrientation: 'from-image' }) / browser-image-compression handles it; the Expo manipulator outputs upright pixels). policy.image.keepExif: true (host-level only, not per form) re-attaches the original APP1 minus GPS via preserveExif — intended for forensic deployments; the default is off because gallery photos leak home locations.

Watermark (policy.image.watermark or props.ext["dev.rasd.media"].watermark): a semi-transparent strip at bottom-start with capturedAt (localized, Intl), lat/lng ± accuracy and optional text, drawn at 3 % of the long edge, before encoding; burnt in, not reversible.

Annotation (props.annotate): after capture, a full-screen canvas over the (already downscaled) photo with pen, arrow, rectangle, text, four colours, undo/clear; strokes use the same Stroke model as signatures and are composited into the JPEG (ODK annotate semantics — the original is not kept). Native composites via react-native-view-shot captureRef of the image + SVG overlay at the photo's pixel size (or Skia when installed).

Multiple (props.multiple, maxCount 5): a thumbnail grid with add/remove, order preserved, value AttachmentRef[]; the add control disables at maxCount; a retake replaces one ref and marks the old blob for GC (§10).

5.3 Web implementation notes

browser-image-compression 2.0.2 (maxWidthOrHeight: maxPixels, initialQuality: quality, fileType: 'image/jpeg', useWebWorker: true, preserveExif: false) is the default engine; the worker keeps a 12 MP decode off the main thread (research §4.2). Where OffscreenCanvas is absent the library falls back to the main thread; the field shows a determinate progress bar and disables navigation for the duration. Low-end path: createImageBitmap(blob, { resizeWidth, resizeHeight, resizeQuality: 'medium' }) decodes and downscales in one step (peak memory ≈ 4 × target pixels instead of 4 × source pixels). HEIC from iOS is transcoded by Safari when accept="image/*" is used; if a non-decodable blob arrives (createImageBitmap rejects), the file is stored as-is only if ≤ maxBytes and the host allowed image/heic, else RASD_MEDIA_TYPE. Object URLs for previews are created per mount and revoked on unmount.

5.4 Native implementation notes

expo-camera returns a full-size file URI; expo-image-manipulator (ImageManipulator.manipulate(uri).resize({ width|height }).renderAsync()saveAsync({ format: SaveFormat.JPEG, compress: quality })) resizes and re-encodes into Paths.cache, after which the adapter moves the result into the namespace attachments directory (rasd/<ns>/attachments/<id>.jpg, atomic .tmp + rename per 07 §9) and deletes the camera original. takePictureAsync({ quality: 1 }) is deliberate — quality is applied once, in the pipeline. expo-image-picker results carry width/height/fileSize/mimeType used to short-circuit the resize when already ≤ maxPixels. Nothing is written to MediaStore/Photos unless policy.gallerySave is true (then via expo-media-library, host-installed, with the extra usage strings).

6. Barcode / QR

RFD formats use BarcodeDetector names (default ["qr_code","code_128","ean_13"], 04 §10.14); the native adapter maps them:

RFD / BarcodeDetectorexpo-camera barcodeTypesNotes
qr_codeqr
code_128, code_39, code_93, codabarcode128, code39, code93, codabar
ean_13, ean_8, upc_a, upc_eean13, ean8, upc_a, upc_e
data_matrix, pdf417, aztecdatamatrix, pdf417, aztec
itfitf14ITF-14 subset on native; documented difference

Web. if ('BarcodeDetector' in globalThis) and getSupportedFormats() covers the requested set → native detector over a getUserMedia stream (requestVideoFrameCallback when available, else 100 ms interval); otherwise createWebBarcode({ wasmUrl }) (or policy.barcode.wasmUrl) drives barcode-detector 3.2.1 through prepareZXingModule({ overrides: { locateFile: () => wasmUrl } }) so the ~1 MB WASM is self-hosted on the customer origin (precached by their Workbox config; rasd-media-v1 runtime cache, 11 §3) — the ponyfill's default jsDelivr fetch is disabled because it breaks offline and CSP (research §4.3). CSP: the ponyfill compiles WebAssembly, so Chromium hosts must add 'wasm-unsafe-eval' to script-src (Firefox and Safari do not gate WASM on CSP); 'unsafe-eval' is still never required anywhere in Rasd (11 §12). Without that directive the barcode chunk fails to initialise and the field degrades to scanImage() / manual entry rather than throwing. Coverage facts: BarcodeDetector ≈ 76 % global, Safari disabled by default through 26.5, Firefox absent, Android needs Google Play Services (AOSP/Huawei devices take the ponyfill path). scanImage() decodes a chosen photo for devices without a usable camera stream.

Native. CameraView in a sheet with barcodeScannerSettings={{ barcodeTypes }} and onBarcodeScanned({ type, data }); torch toggle; haptic on success (expo-haptics optional); Camera.scanFromURLAsync backs scanImage(); launchScanner() (iOS 16+ VisionKit / Android ML Kit) is opt-in because its UI is not themeable.

UX. Auto-close on first decode; multiple: true appends distinct values with a "Done" button; 200 ms debounce; the decoded value is displayed in an LTR island; allowManual shows a text input under the viewfinder; a scan never triggers navigation. Values are validated by the element's validators (regex) exactly like typed text.

7. Signature

The pad is UI in the renderer; @rasd/media/signature owns the stroke model and PNG export so web and native produce comparable output. Strokes are captured as pointer points with time and pressure, smoothed with Catmull-Rom/quadratic Bézier and variable width (the signature_pad algorithm, re-implemented in ~2 kB; react-signature-canvas's latest tag is an alpha, so it is not a dependency). Export: draw at scale = min(devicePixelRatio, 2), cap the long edge at 1200 px, trim: true crops to ink bounds + 16 px padding, background: '#fff' (opaque by default — transparent PNGs render black in some viewers), penColor from props (#111), PNG via canvas.toBlob('image/png') (web) or react-native-view-shot captureRef(svgRef, { format: 'png', result: 'tmpfile' }) / Skia makeImageSnapshot() (native, no WebView; 07 §4). Typical output 10–30 KB; hard cap 256 KiB → re-export at scale 1. Controls: clear, undo last stroke, landscape hint below 360 px width, minimum 3 points before "Save" enables. Accessibility: the pad is role="img" with a localized label; a keyboard/assistive alternative Type your name renders the typed name in a cursive-neutral font to the same PNG (origin: 'pad', ext: { typed: true }). That alternative is what satisfies WCAG 2.2 SC 2.5.7 — the control itself always offers a non-dragging path, independently of whether the designer also offers consent.method: "tap" (04 §10.12). Signatures are always treated as bind.sensitive in exports and lists.

8. Audio and video

Audio presets (quality, ODK ladder, research §4.4): voice mono 16 kHz 24 kbps (≈ 11 MB/h), standard (default) mono 22.05 kHz 32 kbps (≈ 14 MB/h), high 44.1 kHz stereo 128 kbps (≈ 58 MB/h). maxDurationSeconds default 600 for audio (video defaults to 120 s, §8 below and 00 §7.1) → ≈ 2.4 MB at standard; the recorder shows elapsed time, a running size estimate (onTick, bitrate × elapsed) and stops automatically at the limit; the field shows the estimate before recording starts.

  • Web: MediaRecorder with isTypeSupported negotiation in order audio/webm;codecs=opusaudio/ogg;codecs=opusaudio/mp4 (Safari) → browser default; audioBitsPerSecond from the preset; start(1000) timeslices so a crash loses ≤ 1 s; Chrome WebM lacks duration metadata, so durationMs is measured by the adapter and stored on the CapturedFile/AttachmentRef.ext. Recording pauses when the page is hidden (visibilitychange) and resumes on return; a banner explains.
  • Native: expo-audio useAudioRecorder with custom RecordingOptions (sampleRate, numberOfChannels: 1, bitRate, Android outputFormat: 'mpeg4'/audioEncoder: 'aac', iOS audioQuality) derived from the preset; foreground only (no FOREGROUND_SERVICE_MICROPHONE), setAudioModeAsync({ allowsRecording: true }) while the sheet is open.

Video is never transcoded on device (CPU/battery). Web: <input type="file" accept="video/*" capture="environment"> (system camera, bitrate not controllable) or, with strictCamera, MediaRecorder on a getUserMedia stream at videoBitsPerSecond: 1_500_000; native: expo-image-picker.launchCameraAsync({ mediaTypes: ['videos'], videoMaxDuration, videoQuality }) at the 720p preset. Both hard-stop at maxDurationSeconds (default 120 s for video) and reject files above maxBytes (default 25 MB, 00 §7.1, restated in 04 §10.13) with RASD_MEDIA_LIMIT; the builder warns when maxDurationSeconds × preset bitrate exceeds maxBytes (the old 600 s default at 720p ≈ 110 MB, unreachable within any per-blob cap, which is why the duration default is 120 s). Web ceiling: the Dexie blob store refuses any single blob above 25 MB (RASD_STORAGE_QUOTA, details.reason: 'attachmentTooLarge', 09 §4.2), so the default maxBytes now sits exactly at that ceiling; a host that raises maxBytes above it reaches the larger value only on native (single file 100 MiB) or with createDexieStorage({ blobs: 'opfs' }), and on the default web store the adapter clamps maxBytes to 25 MB while the builder shows the effective limit per platform. All of these ceilings — per-submission budget, per-blob caps, video defaults and the server-side cap — are normative only in 00 §7.1.

9. Files

accept is a MIME allow-list (wildcards image/* allowed); absent ⇒ policy.file.defaultAccept (["application/pdf","image/*","audio/*","video/*","text/plain","text/csv"]). The policy.file.deny list always wins (image/svg+xml, text/html, executables/scripts/archives with executables). After picking, the adapter sniffs magic bytes for PDF/JPEG/PNG/GIF/WebP/MP4/OGG and rejects mismatches with RASD_MEDIA_TYPE; maxBytes (20 MiB) is checked before reading into memory (File.size / expo-document-picker size). Native uses expo-document-picker with copyToCacheDirectory: true, then moves the file into the attachments directory. Filenames are sanitised (NFC, path separators and control characters removed, ≤ 120 chars) and kept in AttachmentRef.name for display only — storage keys are always the attachmentId.

10. Storage and sync interplay

sequenceDiagram
participant U as Enumerator
participant F as ImageField (@rasd/react)
participant M as @rasd/media
participant S as StorageAdapter
participant E as SyncEngine (@rasd/sync)
participant SV as RSP server
U->>F: tap Capture
F->>M: camera.capture with source, maxPixels, quality, geotag
M-->>F: CapturedFile with blob, capturedAt, geo
F->>S: transaction over attachments and submissions — attachments.put + submissions.patch
S-->>F: AttachmentRow with sha256 and bytes
F->>F: engine.setValue(path, AttachmentRef)
U->>F: finalize
F->>S: submissions.patch status queued + outbox.enqueue
E->>SV: POST /v1/submissions:batch
SV-->>E: accepted with serverRev — submission becomes synced
E->>SV: POST + PATCH /v1/attachments (tus, Upload-Metadata carries submissionId, field, sha256)
SV-->>E: 204 with new Upload-Offset
E->>S: attachments.patch status uploaded, remoteId
E->>S: gc() after retention.purgeSyncedAfterDays deletes bytes, keeps the row
  • One transaction. The renderer's shared attachCaptured() helper writes the blob (attachments.put(id, blob | uri, { submissionId, field, mime, name? }) — the BlobStore.put meta shape of 09 §3; bytes and sha256 are computed by the store, not supplied) and patches the submission (data[field]AttachmentRef, submission.attachments[]{ id, field, mime, bytes, sha256, localUri, remoteId: null, status: 'pending' }, spine §6) atomically inside storage.transaction(['attachments','submissions'], …). Hashing is WebCrypto on web and the storage crypto provider on native (expo-crypto digest, or react-native-quick-crypto streaming when installed); files ≥ 8 MiB are hashed in 1 MiB slices so no contiguous ArrayBuffer is needed. capturedAt and geo travel on the AttachmentRef in data (they are RFD data, not blob-store metadata), which also mirrors bytes/sha256 so the server can validate completeness from the JSON alone.
  • Upload timing. An attachment becomes eligible only once its submission has been accepted (sync phase 2 lists pending | uploading | failed blobs whose submission is synced, 10 §4.3), so draft attachments are never uploaded. Uploads run up to policy.attachments.parallel (default 2) at a time with adaptive chunks (5 MiB default, 256 KiB–8 MiB) and are resumable across restarts (HEAD for the offset). On a metered link policy.attachments.onMetered (wifiOnly by default) defers blobs above maxBytesOnMetered while submissions keep syncing. Integrity: a chunk-level Upload-Checksum failure returns 460 and the chunk is re-sent; a whole-file mismatch returns 412 checksum_mismatch, the attachment goes to failed with RASD_ATTACHMENT_FAILED, and the enumerator is asked to retake (10 §2.7). Attachments may reach the server before their submission; unlinked uploads expire after 7 days (Upload-Expires).
  • Statuses are exactly pending | uploading | uploaded | failed (spine §6); useSubmission()/useSync() expose per-attachment progress; the field itself never uploads.
  • Garbage collection. Retake or remove orphans the previous blob — and issues DELETE /v1/attachments/{id} when an upload had already started (10 §2.7). attachments.gc() deletes blobs whose submission no longer exists, plus .tmp residue, after every successful sync and at open(), capped at 200 deletions per pass. Retention, not the ACK, frees the bytes: retention.purgeSyncedAfterDays (default 7, 0 = immediately after ack) deletes the local bytes and keeps the row for the "sent" list for keepSentMetadataDays (90) (09 §4.2, 10 §8).
  • Budgets. Per-field maxBytes; per-submission policy.submissionBudgetBytes 10 MB — the field warns at 80 % and finalize emits W_ATTACHMENT_BUDGET (host may block or raise); storage.estimate() headroom < 100 MB shows a banner and < 20 MB blocks new capture while answers still save (NFR-021); QuotaExceededErrorRASD_STORAGE_QUOTA inline, blob kept in memory for one retry.
  • Encryption at rest. Blobs and thumbnails follow the storage adapter's policy: AES-256-GCM per blob (1 MiB chunks for video) with the non-extractable WebCrypto key on web; SQLCipher DB + file-level AES-GCM for the attachments directory when whole-DB encryption is unavailable on native (16 · Security).

11. Privacy and security

  • PII in photos (faces, ration cards, IDs): designers mark image/signature/audio questions bind.sensitive → encrypted even in field-level mode, masked thumbnails in lists, redacted in unencrypted exports; the builder suggests sensitive: true when a form has a consent element and an image question. On-device face blur is not in v1 (see Open questions).
  • EXIF stripped by default; geo only with props.geotag; audit-location (settings.audit.location) is a separate, form-level opt-in.
  • Never write to gallery/Photos/MediaStore; native files live under the app-private namespace directory, excluded from backups by the config plugin (07 §9); web blobs are never object-URL cached beyond render.
  • Logs never contain answers, tus URLs (capability URLs) or file names; createSentryScrubber() drops attachment payloads (research/11).
  • Untrusted content: file type allow/deny lists and sniffing (§9); barcode values are text and go through the same sanitisation as typed input before display; SVG is denied because it can carry scripts.
  • Optional preventScreenCapture on capture/preview screens (expo-screen-capture passthrough) for sensitive forms.

12. Feature detection and graceful degradation

CapabilityDetect (web)Fallback chain
Geolocation'geolocation' in navigator + secure contexttyped coordinates (allowManual) → question skippable if not required
Camera (viewfinder)navigator.mediaDevices?.getUserMedia<input type="file" capture> → plain file input (origin: 'unknown')
Image pipeline workerOffscreenCanvas, createImageBitmapmain-thread canvas with progress; <img> decode when createImageBitmap is missing
Barcode'BarcodeDetector' in globalThis + getSupportedFormats()barcode-detector ponyfill (self-hosted WASM) → scanImage() from a photo → manual entry
SignatureCanvas 2D (always)typed-name alternative
AudioMediaRecorder + isTypeSupported (Baseline 2021)file pick (audio/*) when host enables → unavailable message
Video<input accept="video/*" capture>file pick → unavailable message
Mapmaplibre-gl peer installed + basemap presenttyped coordinates + geo: link
Wake locknavigator.wakeLockbanner asking to keep the screen on

Native mirrors this: each Expo peer is import()-ed on first use; a missing peer yields capabilities flags and the same fallbacks (never undefined is not a function). <RasdProvider> without media renders every capture question with its fallback and marks the form degraded in devtools.

12.1 Failure modes

Every row is a state the enumerator can reach in the field. The invariant is that no capture failure can lose an answer or trap the interview: the draft is already autosaved, and a failed capture leaves the rest of the page usable.

FailureDetected bySurfaces asBehaviour
Permission denied (askable)adapter requestPermission()RASD_MEDIA_PERMISSION reason: 'denied', retryableinline hint + "Try again"; fallback offered; audit permission_denied
Permission blockedcanAskAgain === false / silent web resolveRASD_MEDIA_PERMISSION reason: 'blocked'"Open settings" (native) or browser instructions (web); fallback offered
No hardware / API and no fallbackcapabilities flags, available === falseRASD_MEDIA_UNAVAILABLEquestion renders read-only with an explanation; not required-blocking unless the designer forced it
GPS never converges60 s timer with no fix ≤ accuracyThresholdnon-blocking warningUse best / Keep trying / Place on map / typed coordinates
Mock location providerAndroid mocked: truepersistent field warningvalue stored, meta.ext["dev.rasd.geo"].mocked = true for analysts
Photo will not decode (HEIC, corrupt)createImageBitmap rejectsRASD_MEDIA_TYPEstored as-is when ≤ maxBytes and the MIME is allowed, otherwise rejected with a localized message
Cannot reach the size budgetquality floor 0.5 reachedRASD_MEDIA_LIMIT { limit, actual, kind: 'bytes' }offer a smaller maxPixels or discard; nothing partial is stored
Recorder dies mid-take (tab crash, call interrupt)MediaRecorder start(1000) timeslices / expo-audio interruptionpartial CapturedFile≤ 1 s lost; the partial take is offered for keep-or-discard, never auto-saved
Device storage fullQuotaExceededError, storage.estimate()RASD_STORAGE_QUOTAbanner below 100 MB headroom, capture blocked below 20 MB, answers still save (NFR-021); blob held in memory for one retry
Blob corrupt at upload timelocal re-hash ≠ recorded sha256RASD_ATTACHMENT_FAILEDattachment failed; retake prompt; submission stays synced and is not re-sent
WASM blocked by CSP or missingponyfill init rejectslogged once, no user errorbarcode degrades to scanImage() then manual entry (§6)
Map peer or basemap absentMapAdapter.available === falseno errortyped coordinates + geo: link (§4.4)
Adapter left running on unmountcontract testdispose() stops tracks/watches; a leaked camera is a test failure, not a runtime warning

13. Performance on low-end devices

Targets on a 2 GB-RAM Android 9 phone (Snapdragon 4xx class) and its Chrome/WebView ≥ 100: 12 MP → 1280 px JPEG in ≤ 1.5 s off the main thread, peak heap growth ≤ 60 MB, thumbnail ≤ 150 ms; barcode decode ≤ 300 ms per frame with the WASM ponyfill (frames sampled at ≤ 10 fps, downscaled to 640 px before decode); geolocation first fix rendered ≤ 200 ms after the OS callback; signature pad ≥ 30 fps input at 100 Hz pointer events (coalesced events, pointerrawupdate where available); video/audio never decoded or re-encoded on device. Cameras/streams are stopped (track.stop(), remove()) on unmount and on visibilitychange so a backgrounded form does not hold hardware. All heavy code is lazy (§1); preloadElements(['image','geopoint']) warms chunks for offline first-run (06 §16).

14. Accessibility

Capture questions are the least accessible part of most survey apps; the general contract lives in 13 · i18n, RTL & accessibility §11–§12 and the part names in 12 · Theming §5.2 (GeoPointField, GeoPathField, ImageField, AudioField/VideoField, FileField, BarcodeField, SignatureField). What this package adds:

  • Names, not icons. Every control has an explicit accessible name in the form locale — "Capture location", "Take photo", "Retake photo 2 of 3", "Scan barcode", "Record", "Stop recording", "Clear signature". Icon-only buttons are never shipped without a name. Screen readers never hear an attachmentId, a file path or a tus URL.
  • Status is text, not colour. Accuracy state (acquiring · acceptable · poor) is icon + text + value; the "± N m" readout is announced through a throttled aria-live="polite" region on web / announceForAccessibility on native, at most once per 2 s so TalkBack is not flooded by a 1 Hz fix stream. Recording elapsed time and the running size estimate follow the same throttle.
  • No capture depends on dragging (WCAG 2.2 SC 2.5.7). The signature pad ships the Type your name alternative (§7); map placement always has typed decimal degrees next to it; geotrace vertices are added with an Add point here button, and each vertex is listed with a Remove action rather than requiring a drag.
  • Target size (SC 2.5.8): capture, retake, remove, undo and viewfinder overlay controls resolve to control.minTouch (48 px in every bundled theme, hard floor 24 px — spine §10, 12 §9); overlay buttons keep a 3:1 contrast ring against arbitrary camera imagery, so they sit on an opaque scrim, never directly on the video.
  • Timing (SC 2.2.1): the 60 s geopoint timeout and maxDurationSeconds auto-stop end an acquisition, never the interview, and both are recoverable (Keep trying / record again) — no captured value is discarded on a timer.
  • Focus. Opening a viewfinder, scanner or recorder sheet traps focus while it is open and returns focus to the trigger on close (native sheets set accessibilityViewIsModal); a successful capture announces the result ("Photo added, 2 of 3") and moves focus to the new thumbnail, not to the top of the page.
  • Non-text content (SC 1.1.1): thumbnails get alt = question label + index; the signature preview is role="img" with a localized label; audio/video previews expose duration as text so a screen-reader user can verify a take without playing it.
  • Bidi. Coordinates, accuracy values, barcode payloads, durations and file sizes render in LTR islands (13 §8); digits follow settings.numbering for display while stored values stay ASCII.
  • Motion and haptics. Viewfinder pulse/scan-line animations are suppressed under prefers-reduced-motion / motion.reduced; scan-success haptics are additive to the visible and announced confirmation, never the only feedback.
  • Errors are field messages. Permission, limit and type failures render through FieldWrapper's message part with aria-describedby/aria-invalid, not as a transient toast, so they survive a screen-reader re-read and appear in the finalize error summary.

15. Testing

  • @rasd/testing fakes: fakeMedia({ geolocation: { fixes: [{ lat, lng, accuracy: 30, capturedAt }], permission: 'granted' }, camera: { files: [jpegFixture] }, barcode: { results: ['RC-123'] }, audio: { durationMs: 60_000, bytes: 240_000 }, permissions: { camera: 'blocked' } }) returns deterministic MediaAdapters (17 §14 owns the signature; origin and the other provenance fields are set on each CapturedFile fixture). fixes play back on a fake clock so timeout and convergence paths are unit-testable; renderForm(def, { media: fakeMedia(…) }) on both platforms.
  • Pipeline goldens: fixture JPEGs with EXIF orientation 1–8 and GPS; assert output has no APP1 segment, is upright, long edge ≤ maxPixels, size within 150–350 KB for the 12 MP fixture, sha256 equals a digest of the stored bytes (exact bytes differ per platform encoder, so hashes are not compared across platforms), thumbnail present. Signature goldens: same strokes → trimmed PNG within ± 2 px bounds on web and native.
  • Web E2E (Playwright): context.grantPermissions(['geolocation','camera','microphone']), context.setGeolocation(), Chromium --use-fake-device-for-media-stream --use-file-for-fake-video-capture=qr.y4m for barcode; offline project runs the ponyfill with WASM served from the local origin; a permission-denied project asserts every fallback; RTL project checks LTR islands.
  • Native E2E (Maestro on the Expo example): adb emu geo fix, emulator virtual-scene camera, denied-permission flows, kill during upload → resume from offset.
  • Contract tests: every adapter implementation runs the shared mediaAdapterSuite() (cancel resolves null, abort signal honoured within 500 ms, errors are RasdError with the codes in §2, dispose() idempotent, no console output).

16. Acceptance criteria

Traceability: these cover FR-080 – FR-087 and NFR-021 (02 §4.9).

  • <RasdProvider> without media renders every capture question with its fallback; no crash, degraded flagged.
  • Geopoint: fake 30 m fix warns and can be accepted; ≤ 5 m fix auto-accepts; 60 s timeout offers best/retry/manual; mocked fix writes meta.ext["dev.rasd.geo"].mocked; manual entry validated and flagged.
  • Geotrace/geoshape: auto mode filters by accuracy and 2 m spacing; shape closes to a ring; length/area readouts match distance()/area() on the same points within 0.1 %.
  • Photo: 12 MP fixture → ≤ 350 KB, ≤ 1280 px long edge, upright, no EXIF; sidecar capturedAt always and geo when geotag; annotate/watermark burnt in; multiple/maxCount enforced; retake GCs the old blob.
  • Barcode: formats mapped correctly on native; ponyfill decodes offline in Firefox and Safari with self-hosted WASM; manual entry available; no network request to a CDN.
  • Signature: trimmed opaque PNG ≤ 30 KB typical, ≤ 256 KiB always; clear/undo keyboard-reachable; typed-name alternative produces a PNG.
  • Audio: 60 s standard recording ≤ 250 KB; auto-stop at maxDurationSeconds; size estimate shown before start; durationMs recorded on Chrome WebM.
  • Video/file: maxBytes, accept, deny list and sniffing enforced with RASD_MEDIA_LIMIT/RASD_MEDIA_TYPE and localized messages.
  • Storage: blob + submission patch are atomic; sha256/bytes on the AttachmentRef equal the stored blob; statuses transition only pending → uploading → uploaded | failed; after retention.purgeSyncedAfterDays the bytes are gone and the row remains.
  • Sync: no attachment is uploaded while its submission is a draft; a killed upload resumes from HEAD; a forced 412 checksum_mismatch moves the attachment to failed with RASD_ATTACHMENT_FAILED and prompts a retake.
  • Budget: 80 % warning and W_ATTACHMENT_BUDGET at finalize with the 10 MB default; capture blocked below 20 MB headroom while answers still save.
  • Permissions: rationale shown before the OS prompt; denied vs blocked UX; RASD_MEDIA_PERMISSION emitted once per question; audit permission_denied recorded; merged Android manifest contains no background-location, background-audio or media-read permissions.
  • Privacy: no file appears in the gallery/Photos; object URLs revoked on unmount; logs contain no answers, filenames or tus URLs.
  • Accessibility (§14): vitest-axe clean on every capture story in en and ar; every control has a role and a localized name; accuracy announcements throttled to ≤ 1 per 2 s; signature and map both usable without a drag; capture controls ≥ 48 px.
  • Failure modes (§12.1): each row is reproduced in CI or the device run and produces the stated code and fallback; none loses an answer.
  • Performance targets in §13 met on the reference low-end device in the nightly device run.
  • Every adapter passes mediaAdapterSuite(); fakeMedia() covers all capabilities; size-limit budgets in §1 hold.

Open questions

  • Settled: both reservations offline basemaps depend on are now granted by sibling documents. The basemap: attachment-id prefix is reserved for host assets that have no owning submission, that attachments.gc() never deletes and that estimate() still counts (00 §7, 09 §3, 09 §4.2); sync phase 2 skips every attachment whose submissionId is the $asset sentinel, so host assets are never uploaded (10 §4.3). Still open only as a refactor: whether @rasd/storage should eventually gain a first-class assets store instead of reserved prefixes in the blob store.
  • 11 §12's CSP table does not list 'wasm-unsafe-eval', which Chromium requires for the barcode ponyfill (§6). Add the directive there, or accept that strict-CSP Chromium hosts lose ponyfill scanning.
  • Should watermark and keepExif become first-class image props in RFD v1.1 rather than policy/ext?
  • On-device face/ID blurring (privacy-by-design for beneficiary photos): no maintained cross-platform detector today — revisit for v2.
  • PMTiles support in MapLibre Native must be verified per release before recommending it over offline packs.
  • Should draft attachments upload early by default on native (where connectivity is opportunistic) while staying off on web? Today sync phase 2 gates on synced submissions (10 §4.3); an early-upload policy flag does not exist yet and would need to be added to SyncPolicy.attachments.