ADR-0027: Transaction attachments stored on-device in app files
Context
Users want to attach files to a transaction (txt, images, pdf, csv, xlsx, docx). The app is offline-first (ADR-0015) and its security posture is "everything stays on device" with an encrypted DB (ADR-0010).
Decision
- Bytes live in the app-private
filesDir/attachmentsdirectory under a UUID filename; the encrypted Room DB stores metadata only in a newattachmentstable (schema v8,MIGRATION_7_8):id,transactionId(FK CASCADE),fileName,mimeType,sizeBytes,type(enumdbValue),storedPath,createdAt. - At-rest protection relies on the OS file-based encryption of app-private
storage plus the existing
allowBackup=false/data_extraction_rulesguards (no cloud copy path). AttachmentRepositoryowns the file lifecycle — FK CASCADE drops rows but not files, so the repository deletes matching files when an attachment or a transaction is removed.- Validation is pure and unit-tested: MIME/extension whitelist (txt, image,
pdf, csv, xlsx, docx) + a 10 MB per-file cap, mapped to typed
StageResult(Ok | UnsupportedType | TooLarge | ReadFailed) for Snackbar feedback. - Draft-then-save: attachments are staged (file copied, no row yet) as the
user adds them and persisted atomically with the transaction save via
replaceAttachments. New (manual/unmatched) transactions can carry attachments because files stage before the transaction id exists. - Not exported in CSV/JSON (v1) —
ExportCodecis text-only and the binary bytes stay out of the portable export.
Consequences
- Encrypted-at-rest posture is preserved without duplicating a second crypto key; deleting a transaction removes its attachments.
filesDirgrows with attachment bytes; the 10 MB cap bounds a single file.- Attachments are not portable via export/import (documented limitation).
Rejected alternatives
- BLOB bytes in the SQLCipher DB — keeps encryption explicit and simplifies file lifecycle, but bloats the DB and pulls bytes through Room flows; files-on-disk keeps the DB light.
- SAF persistable URIs — not durable across uninstall/provider changes.
- Base64 in JSON export — export bloat and portability mismatch for binary.