Skip to content

Claude/barcode entity data layer y9tt6d - #1544

Open
stec314 wants to merge 31 commits into
Futsch1:mainfrom
stec314:claude/barcode-entity-data-layer-y9tt6d
Open

Claude/barcode entity data layer y9tt6d#1544
stec314 wants to merge 31 commits into
Futsch1:mainfrom
stec314:claude/barcode-entity-data-layer-y9tt6d

Conversation

@stec314

@stec314 stec314 commented Jul 7, 2026

Copy link
Copy Markdown

No description provided.

Ste and others added 30 commits July 7, 2026 00:31
…itive (Futsch1#1541)

Data layer only (patch 1 of 2). Adds a dedicated Barcode table (many-to-one
to Medicine, FK CASCADE) so one medicine can carry multiple package barcodes,
built purely from the user's own scans with no external drug database.

- BarcodeEntity/Dao/Repository + domain model & interface
- Hilt wiring in DatabaseModule
- Room version 24->25 via AutoMigration (new table, auto-generated CREATE)
- MedicineDao.increaseStock mirroring decreaseStock, exposed on the repository

Scanner UI (ZXing + camera) follows in patch 2.
Hand-authored since KSP/Room compiler can't run in this sandbox (no
network access to dl.google.com). Mirrors the AutoMigration(24, 25)
generated by the entities in MedicineRoomDatabase.kt: same 24.json
content plus the new Barcode table (FK to Medicine, CASCADE). The
identityHash is a placeholder — Room will recompute and overwrite it
correctly the next time someone runs a real Gradle build with KSP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FsQHdwWFdGcCmSAjLCB5Y6
…1#1541)

Patch 2 of 2 (UI, on top of the data layer from the previous commits).
Wires an offline barcode scan into the medicines list:

- "Scan barcode" overflow menu item on MedicinesFragment, following the
  existing medicinesProvider callback pattern on MedicinesMenu.
- BarcodeScanner (new): requests CAMERA permission (mirrors
  RequestPostNotificationPermission), launches the ZXing embedded
  CaptureActivity via IntentIntegrator.createScanIntent() +
  registerForActivityResult (kept off the deprecated
  Fragment.onActivityResult path used by IntentIntegrator.initiateScan(),
  since this project's lint runs with warningsAsErrors).
- Known barcode -> medicineRepository.increaseStock(id, refillSize)
  directly, closing the loop on the increaseStock primitive added in the
  data-layer patch.
- Unknown barcode -> medicine picker dialog (MaterialAlertDialogBuilder,
  same shape as ManualDose/DeleteHelper), then link + increaseStock.
- CAMERA permission + optional camera feature declared in the app
  manifest; zxing-android-embedded added to the version catalog and to
  feature/ui (already depends on core:domain and feature:reminders, no
  other module wiring needed). No JitPack entry needed, it's on Maven
  Central.

Not verified here: this sandbox can't reach dl.google.com, so the AGP/
Room/Hilt toolchain can't run — this hasn't been compiled or lint-checked.
Two specific things worth checking on a real build:
- Android Lint's MissingTranslation: the new strings only exist in the
  base values/strings.xml, not the 25+ locale files.
- zxing-android-embedded 4.3.0 (last release) against compileSdk 37 /
  recent AGP - the library itself hasn't shipped a new version in years.
workflow_dispatch-only, no signing secrets needed (debug builds use the
auto-generated debug keystore) - for testing a branch's APK before merge,
e.g. the barcode scanner feature which needs a real device/camera.
workflow_dispatch alone won't show up until the file lands on main, so
add a push trigger scoped to this branch for now to actually get a
runnable debug APK before merge.
…tism

Calling medicineRepository.increaseStock() directly bumped the stored
amount but skipped what every other refill in this app does: RefillProcessor
(reached via ReminderProcessorBroadcastReceiver.requestRefill, same call
StockSettingsFragment.refillNow() makes) also logs a ReminderEvent of type
REFILL, which is what shows the restock in the medicine's history/overview.
A barcode-triggered restock going through the old path would have been
invisible there. Both the known-barcode and link-then-refill flows now go
through requestRefill() instead.

Leaves increaseStock() in MedicineRepository unused for now - it stays as
the decreaseStock-symmetric primitive for a future "increase by an
arbitrary amount" UI action, but isn't the right call for this feature.
Answers the request for phone-tap automation (like Samsung Routines, but
for an action inside the app, not just "open app"): Samsung Routines can't
call into third-party app internals, but Android's own NFC dispatch can -
if a tag holds an NDEF URI record matching a declared intent-filter, the
OS opens that app directly with the parsed URI, no OEM automation layer
needed, works on any phone.

- NfcActionActivity (feature/ui): trampoline for medtimer://take?medicineId=N
  and medtimer://refill?medicineId=N. "take" logs an ad-hoc TAKEN
  ReminderEvent and runs it through requestStockHandling (same stock
  decrease + out-of-stock check as any other taken dose); "refill" reuses
  requestRefill, same as the barcode scanner. No dialogs - meant to run
  unattended off a tag tap, feedback is a Toast.
- feature/ui gets its own AndroidManifest.xml (didn't have one before) to
  declare the activity + NDEF_DISCOVERED intent filters, scoped to
  scheme "medtimer".
- Stock settings screen gets a new "NFC automation" category with two
  preferences that copy the take/refill link (with this medicine's real
  ID) to the clipboard, to paste into any NFC tag-writing app.

Security note: kept this to NDEF_DISCOVERED only, deliberately not also
ACTION_VIEW/BROWSABLE - the activity is exported (required for NFC
dispatch) and state-changing with no confirmation, so it shouldn't also
answer to a link any other installed app or web page could construct.
NDEF_DISCOVERED is exposed the same way in principle (Android doesn't
restrict who can send that action), but this at least doesn't add a
second, wider entry point on top of it. Worth knowing given this is
medication-adherence data, even though there's no auth/destructive
action exposed - worst case is a spoofed taken-dose or stock entry.
…otification

Follow-up to the NFC automation request: Samsung Routines can't observe a
third-party app's internal stock state directly, only react to
notifications the app posts - and automatic/silent SMS sending needs
SEND_SMS, which Play Store restricts to default SMS/dialer apps, too
risky to add to a published app. So instead of trying to make Routines
send the SMS, MedTimer now prepares it: a "Request prescription" action on
the existing out-of-stock notification opens the system SMS composer
pre-filled with a message and the configured contact, one tap to send, no
dangerous permissions.

- Medicine gains prescriptionContact (phone number), set per-medicine in
  a new "Prescription request" section of the stock settings screen.
- OutOfStockNotificationFactory adds the action (only when a contact is
  set) via PendingIntent.getActivity + ACTION_SENDTO/smsto:, same shape
  as the existing refill action.
- Room 25->26: additive column on Medicine, AutoMigration, hand-written
  schemas/26.json (still can't run the real Room/KSP compiler here, same
  network limitation as the earlier patches).
- MedicineBackup/BackupMappers updated so the contact survives backup/
  restore and BackupFieldParityTest stays green.
zxing-android-embedded's own manifest hardcodes
android:screenOrientation="sensorLandscape" on CaptureActivity, so the
scanner was landscape-only regardless of how the phone was held.
Override it via manifest merge to fullSensor so it follows the device's
actual orientation, portrait included.
The scanner was only reachable from the Medicines list's overflow menu.
Adds a second entry point: a small icon FAB stacked above the existing
"Log additional dose" FAB on the Overview screen (portrait + landscape
layouts), matching its style. Reuses the same BarcodeScanner class
(already Fragment-agnostic via registerForActivityResult on whichever
Fragment constructs it), instantiated the same way MedicinesFragment
does - no changes needed to BarcodeScanner itself.

New upc_scan icon (Bootstrap Icons, matching the rest of this project's
icon set - capsule/cart2/box_seam/etc. are all from the same set) since
no scan/barcode drawable existed yet.
…verride

tools:node="merge" alone isn't enough when the attribute value actually
conflicts with what the library declares (fullSensor vs its
sensorLandscape) - the manifest merger rejects that as ambiguous and
wants an explicit tools:replace. Confirmed via the CI debug build, which
failed on this exact merge error.
BREAKING (data): drops the Barcode table (24->25's feature) entirely -
replaced by MedicineLabel, same many-to-one/FK-cascade shape but keyed
on normalized OCR'd text instead of an exact barcode string, since photos
of the same box are never byte-identical the way a decoded barcode is.

- PackageScanner (feature/ui) replaces BarcodeScanner: launches the
  system camera app (ActivityResultContracts.TakePicture + the existing
  FileProvider) instead of ZXing's CaptureActivity, then runs OCR via a
  new PackageTextRecognizer interface. Matches recognized text first
  against remembered MedicineLabel snippets, then against every
  medicine's own name (substring match, case/whitespace-normalized).
  Exactly one match -> refill immediately (same requestRefill automatism
  as the old barcode flow and the stock settings "Refill now" button).
  Zero or multiple matches -> medicine picker dialog, remembers the pick
  as a new MedicineLabel for next time.
- PackageTextRecognizer is ML Kit (com.google.mlkit:text-recognition) in
  the "full" flavor only and a NoOp (isSupported = false) in "foss",
  mirroring this codebase's existing GeofenceRegistrar/LocationProvider
  full-vs-foss split exactly (interface in src/main, impl + Hilt @BINDS
  module per flavor source set) - foss never sees a broken scan button,
  the Medicines-list menu item and Overview FAB both check isSupported.
- Room 26->27: entities list drops BarcodeEntity, adds MedicineLabelEntity
  (AutoMigration handles both the DROP TABLE and CREATE TABLE - pure
  add/remove, no spec class needed, same as prior additive migrations in
  this file). Hand-written schemas/27.json, same caveat as every schema
  file in this branch: no dl.google.com access here to run the real
  Room/KSP compiler, so this wasn't generated, it was written to match.
- Removed the zxing-android-embedded dependency and its CaptureActivity
  manifest override entirely.

Known rough edge, stated plainly: substring matching against medicine
names is genuinely fuzzy - a short or generic medicine name could match
text on an unrelated package. This mirrors the trade-off already accepted
for the old barcode "ask once, remember" flow, just with a noisier signal
than an exact barcode string.
…RFID tag

Three more pieces of the automation request, on top of the NFC take/
refill links and per-medicine prescription contact already in place:

- Grouped SMS: the out-of-stock notification's "Request prescription"
  action now routes through NfcActionActivity (new requestPrescription
  host) instead of building the SMS inline in OutOfStockNotificationFactory.
  At tap time it gathers every currently out-of-stock medicine sharing
  the same prescriptionContact and drafts ONE SMS listing all of them,
  computed fresh rather than baked in at notification-build time (more
  correct anyway - what's "also low" can change between when the
  notification was built and when it's tapped). Had to go through an
  explicit intent built from a class-name string rather than
  NfcActionActivity::class.java, since feature:ui already depends on
  feature:reminders - a compile-time reference the other way round would
  be a module cycle.
- Pickup calendar reminder: the same tap also opens the system calendar
  app pre-filled with a reminder N days out (reusing the existing
  createCalendarEventIntent helper, same one-more-tap-to-confirm shape as
  the SMS draft). N is a new global "days until pickup reminder" setting
  (UserPreferences.prescriptionPickupDays, default 3), configurable on
  the new settings page below. Threaded through the settings backup/
  restore path too, with a guard for old backups: Gson bypasses Kotlin
  constructor defaults for a field it doesn't find in the JSON, so a
  pre-existing backup missing this key would silently restore it as 0,
  not 3, without the explicit "treat 0 as absent" check added here.
- Generic RFID/NFC tag: medtimer://takeScheduled (no medicineId) marks
  every currently RAISED dose reminder as taken in one tap - meant for a
  single physical tag used every day, "which meds" comes from what's due
  right now rather than which tag it is. Excludes out-of-stock/expiration/
  refill events (different action, not a dose). Reuses
  requestReminderAction(..., reminder = null, ...), the same primitive
  ReminderEventActions already uses for the plain "Taken" button - passing
  null deliberately skips the variableAmount prompt, so this stays a
  zero-dialog tag tap even for reminders normally configured to ask.
- New "NFC/RFID automation" settings page (feature/ui/preferences/
  NfcRfidSettingsFragment + nfc_rfid_settings.xml), reachable from the
  main settings screen: explains both tag strategies (one generic tag vs.
  per-medicine tags copied from each medicine's Stock settings), exposes
  the takeScheduled link to copy, and the pickup-days field. Per the
  request, MedTimer only tells you what to write on a tag - it doesn't
  write NFC tags itself.
Room's AutoMigration won't infer "table removed" on its own when an
entity disappears from the @database list in the same migration step
another entity gets added - it can't tell that apart from a rename, and
refuses to guess. Confirmed by the CI debug build failing at
kspDebugKotlin with exactly this: "AutoMigration Failure: Please declare
an interface extending AutoMigrationSpec... @DeleteTable". Added
AutoMigration26To27 with @DeleteTable(tableName = "Barcode") and wired
it into the 26->27 AutoMigration's spec.
…cellation arg

import kotlinx.coroutines.resume resolved to the wrong (internal)
overload in this coroutines version - CI failed with "Cannot access
DispatchedTask.resume: it is internal in file". Call the actual member
resume(value, onCancellation) directly instead of relying on the
single-arg convenience extension.
Declaring android.permission.CAMERA without ever requesting it at
runtime makes some OEM camera apps (notably Samsung's) throw a
SecurityException when launched via ACTION_IMAGE_CAPTURE, crashing the
caller. Nothing in the app touches the Camera API directly - package
scanning only delegates to the system camera app - so the permission
was unnecessary; removing it lets the implicit capture intent work
without a permission grant.

Also moves the scan FAB out of the stack above "Log additional dose"
and into the same bottom row (mini FAB beside it) so it doesn't look
like an add-on floating above the main action.
OverviewFragment.setupScanPackage() does findViewById<Button>(R.id.scanPackage);
swapping the widget to a plain FloatingActionButton (an ImageButton, not a
Button) made that cast throw a ClassCastException as soon as the Overview
screen - the app's start destination - was shown, crashing on launch.
ExtendedFloatingActionButton extends Button, so it's compatible with that
lookup while keeping the FAB in the same bottom row as "Log additional dose".
The old flow required a shutter press then ran OCR once on that photo;
now the camera preview stays open (CameraX) and every frame is OCR'd
live, matched per text block so several distinct packages can be
recognized one after another (or together in frame) without reopening
the screen.

Pill-count handling: parses the package's quantity from its own text
(common Italian/English packaging phrasings) and refills stock with
that exact count; when it can't be parsed, asks once and remembers the
answer against that package's text so it's silent next time. Ambiguous
packages (no/multiple medicine-name matches) show a picker instead of
guessing.

CameraX has no Play Services dependency, so it's a plain dependency
usable from both flavors; only the ML Kit text-recognition step stays
full-flavor-exclusive as before, keeping the quick-access button hidden
in foss. Re-adds the CAMERA permission (removed after the earlier
capture-intent crash) since this flow now genuinely needs it, but
requests it at runtime via ActivityResultContracts this time.

MedicineLabel gains an optional remembered quantity (DB v27->28,
add-only column, no migration spec needed). RefillProcessor/
ReminderProcessorBroadcastReceiver gain an optional explicit amount so
a refill can use the scanned count instead of the medicine's configured
refill size.
It calls the suspend askWhichMedicine dialog helper; CI caught the
missing modifier as a compile error.
…back

A single package's text is OCR'd as several separate blocks (brand
name, dosage, pack size, manufacturer...). Only the block containing
the medicine's own name actually matched anything; every other block
from that same, already-identified package was independently tracked
as a potential "unknown package" and eventually popped the picker
dialog too - even though the medicine had already been recognized and
refilled. Now a frame's stray blocks only count towards that prompt
when nothing in the same frame matched a medicine at all.

Also replaces the easy-to-miss toast with an on-screen banner (auto
-dismissing) that flashes over the camera preview when a package is
recognized, so there's a clear visual confirmation right where the
user is looking instead of at the bottom of the screen.
…ings menu

Replaces the per-medicine "prescription contact" field with a single
global phone number and an editable SMS message template ({medicines}
placeholder), managed from a new dedicated "Prescription request"
settings screen alongside the pickup-reminder delay (moved out of the
NFC/RFID screen, since it isn't NFC-specific).

Since there's now one contact instead of many that had to match, the
out-of-stock notification's "Request prescription" action combines
every currently out-of-stock medicine into one SMS unconditionally,
instead of only medicines that happened to share the exact same
per-medicine contact string.

DB migration 28->29 drops Medicine.prescriptionContact (plain
DeleteColumn, no data worth preserving since it's superseded by the
global setting). UserPreferences/SettingsBackup/BackupMapper gain
prescriptionContact and prescriptionMessageTemplate, following the
same nullable-on-restore guard already used for prescriptionPickupDays
(Gson bypasses the constructor for fields backups made before they
existed, so these must tolerate a literal null coming back from an old
backup file).
…g menu

Live scanning now considers one package candidate at a time: the
instant any OCR text block looks like it belongs to a package (whether
it resolves immediately or needs a dialog), analysis freezes entirely
until the user taps "Next". Previously, only the same *frame* that
matched a medicine suppressed its own stray blocks (dosage, pack size,
manufacturer text OCR'd as separate blocks) from being mistaken for a
second, unknown package - but a later frame that only caught one of
those stray fragments (without the name block reappearing) could still
accumulate towards the "couldn't recognize this package" prompt even
after a correct, already-completed add. Freezing the whole session on
the first candidate removes that race entirely, and doubles as the
one-scan-then-confirm flow that's easier to follow than continuous
recognition.

Also removes the "Generate test data" / "Generate test data and
events" debug-only menu items (and the now-orphaned GenerateTestData
class) - a few androidTest UI tests used them as a setup step and will
need a different one if that instrumented suite is ever run again; it
isn't part of the debug-APK CI build this branch has been validated
against.
Name and remembered-label matching required the OCR'd block to
literally contain the medicine name or label text after only
lowercasing and whitespace-collapsing. Real packaging text almost
never agrees with the app's own naming convention on spacing -
medicine names here are typically written "500mg" (no space) while
boxes print "500 mg" (with one), and OCR line breaks add more noise on
top - so the containment check was failing even on an exact visual
match, both for the initial name lookup and for a label already
remembered from a prior successful scan. That's why the "couldn't
recognize this package" picker kept reappearing for medicines it had
supposedly already learned.

Compares now use a fuzzy key (lowercase, letters/digits only, every
other character stripped) so "500mg" and "500 mg" are equivalent.
QuantityParser still runs against the lightly-normalized (spaced) text
since it depends on word boundaries to tell "500" from "500mg" from
"n. 30".
Shows exactly what text OCR is extracting each frame, updated live
(similar to a live camera-translation overlay), so it's obvious when
the camera just hasn't gotten a clean read yet versus the app failing
to recognize something it can plainly see.

Also replaces the "ask which medicine" trigger's frame-repeat-counting
with a real elapsed-time check: OCR text is rarely byte-identical
frame to frame (autofocus hunting, angle, blur), so requiring the same
exact string to reread twice could fire almost instantly on a
coincidentally-repeated blurry partial read, well before the camera
settled on a clean view of the box. Now it waits a few seconds of
continuous non-matching before prompting, using the longest candidate
seen during that window.
Some medicines (OTC, or ones whose prescription is renewed only
rarely) shouldn't be swept into the combined out-of-stock SMS just
because they happen to run low too. Adds a per-medicine switch in
Stock settings; the combined message now skips any medicine with it
enabled.

DB migration 29->30 is a plain added column (default false = included,
matching every other existing boolean Medicine field's polarity) so
restoring an old backup that predates this field can't silently
exclude everything - Gson bypasses the Kotlin constructor default on
restore and falls back to the JVM zero-value for missing primitives,
so the field's own default has to already be the safe one.
…rmission loss

Package matching required the medicine's whole name to appear as one
unbroken substring of the OCR'd block. OCR noise (a misread logo, an
inserted line break, stray characters from nearby graphics) tends to
land between words rather than corrupt them, so this was pickier than
the packaging text actually supports. Now matching is word-by-word:
any dosage-looking word ("500mg") must match outright since it's what
distinguishes same-brand variants, while the remaining brand words
only need a majority - tolerating exactly the kind of in-between noise
that was causing correct packages to fall through to "couldn't
recognize this package."

Also splits the generic "backup failed" message: the automatic backup
folder's SAF permission gets revoked on every reinstall (including a
new debug build with a different signature), and that specific case
now gets its own message pointing at re-selecting the folder instead
of a generic failure the user has no way to act on.
…abels

MedicineLabelRepository had no UI surface at all, so a wrong pill count
entered during package scanning could never be corrected. This adds a
settings screen listing every remembered package (medicine name, OCR
snippet, quantity) with per-item quantity editing, per-item forget, and
a bulk "forget all" action.
Editing a logged dose's taken/skipped status or amount from the event
bottom sheet updated the log but never touched the medicine's stock,
silently desyncing the two. Deleting a refill log entry had the same
problem: its stockHandled flag is never set and it has no reminderId,
so the existing undo-stock path was a no-op for it. Both now adjust
stock the same way toggling taken/skipped from a notification does.
UserPreferences (all DataStore settings screens) was already fully
backed up, but PersistentData - analysis window, saved overview
filters, last-used icon color, last custom dose - was collected but
never serialized, so it was lost on a fresh install/new phone. Added
as a new nullable field so old backups without it are a no-op on
restore instead of wiping current values with zero-defaults.

One-time/device-specific flags (intro shown, battery warning shown,
notification id, last backup date) are intentionally left out since
they shouldn't carry over to a new device.
New :wear module (com.futsch1.medtimer.wear) is a thin remote control:
it shows today's dose reminders and sends Taken/Skip/Snooze/Snooze-until-
home requests back to the phone over the Wearable Data Layer, which
drives them through the exact same ReminderProcessorBroadcastReceiver
entry points a notification action button already uses - no business
logic is duplicated on the watch.

Phone side lives in feature/reminders, gated to the "full" flavor only
(mirrors the existing play-services-location gating for "foss"/F-Droid):
GmsWearSyncController pushes a today-filtered snapshot via DataClient,
WatchActionListenerService receives watch actions via MessageClient.

This is phase 1 (list + sync); the pill-shaped quick-action Tile is a
separate follow-up once this is verified green in CI.
PillTileService shows the next raised reminder with the app's pill
glyph; tapping it launches a tiny invisible activity (MarkTakenActivity)
that sends the same TAKEN action the in-app Taken button does, then
requests a tile refresh. Also adds confirmation toasts for the
Taken/Skip/Snooze actions sent from the reminder list screen.

This is the highest-uncertainty part of the watch app (Tiles API
surface verified only by cross-referencing docs, not by compiling) -
expect this to need on-device follow-up.
@Futsch1

Futsch1 commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Please describe the intention and scope of this PR: as it now also adds a WearOS companion app, I no longer see clearly what it wants to change. Additionally, it is getting bigger and bigger and thus very hard to review and test properly.

@Futsch1

Futsch1 commented Jul 8, 2026

Copy link
Copy Markdown
Owner

You will especially have to separate the wear implementation from the barcode feature.

Also, do not increase the database scheme version on every change, just commit one version increment with all your changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants