ADR-0086: Read-only attachment viewing and accessible receipt/location controls
- Status: Accepted
- Date: 2026-11-02
- Deciders: maintainers
- Related: ADR-0012, ADR-0015, ADR-0027, ADR-0030, ADR-0033, ADR-0070
Context
Attachments were write-only after staging: an image could be opened in the
in-app viewer, but a saved PDF/text/CSV/Word/Excel file had no open action. The
receipt viewer also had no announced identity and exposed zoom/pan only through
pinch gestures. The location picker could only move a point by dragging the map
(or canvas) or by accepting the device's last fix, which left a screen-reader
or keyboard user without a bounded way to choose and save an arbitrary valid
point. Attachment bytes are app-private (filesDir/attachments, ADR-0027), so
the solution had to add usability without turning the private ledger or the
whole attachment directory into exportable files.
Decision
Opening saved attachments (#383)
- Images stay in the existing in-app viewer. Tapping the named Open
action or an image thumbnail opens
ImageAttachmentViewerOverlay, which already keeps TalkBack focus in its own dialog window. - Supported non-images are handed to the system's read-only viewer. The
app's non-exported
FileProvidergains anfilesDir/attachmentsfile root inres/xml/file_paths.xml.ACTION_VIEWreceives the provider URI for exactly one saved file plusFLAG_GRANT_READ_URI_PERMISSION; it never carries a write grant, a persistable grant or aClipDatacopy of the bytes. - No plaintext export is created. The viewer reads the original app-private file through the grant; there is no cache copy or staging directory to clean up. The grant ends with the receiving viewer's task.
- Opening is path-safe.
AttachmentRepository.resolvableFileForStoredcanonicalises the stored path and returns a file only when it is an existing regular file whose parent is exactlyfilesDir/attachments. Paths that leave the root (.., absolute paths, nested paths, symlink escapes) and the encrypted ledger therefore never resolve to a provider URI; a normalised path that stays inside the root is still in-bounds, because containment is per-directory rather than per-row. Missing/deleted files produce a localized "no longer available" snackbar; an absent viewer produces "No app can open this file"; a provider failure produces a generic open failure. - Open and Delete are separate named targets. Every attachment row exposes
Open attachment <name>andDelete attachment <name>as 48 dp actions. Delete continues to own file cleanup and remains independent of viewing. - The lock is not weakened. Opening externally leaves the app, so the
existing lifecycle relock (
AppLockManager.onStop) still applies on return. The in-app viewer still disappears whenLocalAppLockedbecomes true (DismissOnLock/ the viewer's own guard).
Receipt viewer identity and non-gesture controls (#384)
- The viewer shows the attachment's file name in a title bar and announces the
image as
Image attachment <name>, giving the receipt meaningful identity. - Pinch/pan remain, but zoom is bounded to 1–6× and every gesture has a named alternative: zoom in/out, reset, and pan left/right/up/down. Controls are 48 dp, disable at their bounds, and reset returns to 100 % with zero translation. The image announces its current zoom as a state description.
- Controls sit on opaque black bars with white content, so contrast does not depend on the photograph underneath (white-on-black is 21:1; disabled white at 50 % is about 5.3:1). The layout wraps the controls on narrow/large-text screens. An unreadable image shows explicit unavailable feedback.
- The viewer makes no OCR or text-extraction claim; it only displays bytes.
Accessible location point selection (#384)
- Both the map and canvas pickers expose Enter coordinates next to Locate
current. The dialog takes latitude and longitude, accepts a decimal point or
comma, rejects non-finite/non-numeric input, and validates WGS-84 bounds
(
-90..90,-180..180). Invalid fields keep the dialog open and show a field error; valid values replace the current point through the existing camera/canvas state used by Save. The camera target is assigned directly, so a missing renderer or a deferred locate animation can neither hang the dialog nor overwrite the typed point with a cached fix. - The path needs no network, location permission, map pack or gesture. The point caption is a polite live region, Save stays outside the dialog and remains reachable at short heights and 200 % text, and keyboard Done applies the point. Save continues to store the same captured-location fields as before (no schema change).
- The picker still auto-locates once on open when there is no point; that is a convenience, never a prerequisite for choosing and saving coordinates.
Consequences
- A supported non-image can be reopened when the device has a viewer. The external app receives temporary read access to that one attachment and can display or share it under its own policy; AutoBudget neither grants write access nor retains a second plaintext copy.
- Deleting an attachment while an external viewer holds the URI can make that viewer's read fail; the app itself still reports deletion/missing files and never crashes.
- No PDF/DOCX rendering dependency, schema migration, network call or map-pack change is introduced. The existing camera cache sweep and draft reservation rules are untouched because viewing creates no staged file.
- Coordinate entry places a point but does not reverse-geocode a place name (ADR-0015); the supported coordinate line and offline basemap remain the source of context.
Rejected alternatives
- Bundle an in-app PDF/Office renderer — a large third-party surface for a feature the platform viewer already provides.
- Copy the file to cache and share that URI — creates a plaintext export and a cleanup obligation; the provider can serve the private original directly with a narrower grant.
- Expose the whole
filesDiror an exported provider — would risk the encrypted ledger and sibling attachments; only the attachments root is mapped, and only an explicit per-file grant is issued. - Add a write or persistable grant — viewing must not become editing or a durable export.
- Open images externally too — loses the existing in-app zoom/pan and lock dismissal contract.
- Keep the viewer gesture-only / add a label alone — fails keyboard, switch and screen-reader users.
- Require a device fix or a map gesture to choose a point — leaves a permission/gesture dead end; typed coordinates use the same existing state.
- Reverse-geocode typed coordinates online — reverses ADR-0015.
Verification
AttachmentRepositoryOpenPathTest pins containment, traversal, symlink,
missing and directory rejection. AttachmentOpenIntentTest pins the provider
URI root, read-only/no-persistable/no-ClipData flags, the no-viewer and
provider-failure results, and rejects a file outside the attachments root.
AttachmentSectionTest pins the named Open/Delete actions
and 48 dp targets. ImageAttachmentViewerOverlayTest covers identity, bounded
zoom/pan/reset including the 6× clamp, close, lock dismissal, unreadable-image
feedback and the disabled no-image controls.
LocationCoordinateValidationTest pins parsing/range rules;
TransactionLocationScreenTest covers typed coordinate apply/save without a
fix, the no-renderer map fallback, a typed point winning over a cached fix,
invalid-input feedback, dialog lock dismissal, 48 dp controls, 320 dp/200 %
dialog entry and Save at short height/200 % text; TransactionDetailScreenTest
covers missing-file feedback.