Skip to content

ADR-0086: Read-only attachment viewing and accessible receipt/location controls

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 FileProvider gains an filesDir/attachments file root in res/xml/file_paths.xml. ACTION_VIEW receives the provider URI for exactly one saved file plus FLAG_GRANT_READ_URI_PERMISSION; it never carries a write grant, a persistable grant or a ClipData copy 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.resolvableFileForStored canonicalises the stored path and returns a file only when it is an existing regular file whose parent is exactly filesDir/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> and Delete 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 when LocalAppLocked becomes 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 filesDir or 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.