Claude/barcode entity data layer y9tt6d - #1544
Open
stec314 wants to merge 31 commits into
Open
Conversation
…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.
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. |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.