# Changelog

All notable changes to the QDat Android application are documented in this file.

## [2.4.13] - 2026-06-27

### Build

- **Verified release.** No functional changes since 2.4.12. Cut as a clean release number after the 2.4.9–2.4.12 reader-action gating and multimodal-window work (stop-inventory/trigger-suppression, on-top feedback toast) was verified on-device (TC22R).
- **Version bump**: `2.4.12` → `2.4.13` (versionCode 103 → 104).

## [2.4.12] - 2026-06-27

### Fixed — multimodal feedback now shows on top of the assembly dialog (`helpers/MultimodalScanDialogHelper`, `MainActivity`)

- Feedback messages raised while the multimodal scan dialog is open (RFID captured, code/OCR added, "scan started", DataWedge "still starting up") were rendering behind the dialog's dim scrim and never seen — a Snackbar resolves up to the activity's CoordinatorLayout regardless of being rooted in the dialog view.
- `MultimodalScanDialogHelper.showMessage(...)` now uses a **Toast** (a system-level overlay that always draws above the dialog window). The DataWedge "scanner still starting up" notice and the "scan started" message are routed through `mmFeedback(...)`, so they too land on top while the dialog is up (and still fall back to an activity snackbar when it isn't).

### Build

- **Version bump**: `2.4.11` → `2.4.12` (versionCode 102 → 103).

## [2.4.11] - 2026-06-27

### Changed — multimodal window: stop RFID inventory + suppress the trigger (`MainActivity`)

- Opening a multimodal scan now **stops any running RFID inventory** (`openMultimodalAssembly`), and **neutralizes the hardware trigger** for the duration of the assembly via a no-op `TriggerController.setPressOverride(...)` — so a trigger press can no longer start RFID inventory inside the multimodal window.
- Within the multimodal window, the **only** RFID capture is the on-screen **"RFID" button** (`captureRfidIntoScan`, which runs its own short sweep). The imager / barcode trigger (DataWedge) is unaffected.
- On dialog dismiss (`restartInventoryAfterMultimodal`) the trigger override is cleared and the prior inventory is restored if it had been running.

### Build

- **Version bump**: `2.4.10` → `2.4.11` (versionCode 101 → 102).

## [2.4.10] - 2026-06-27

### Changed — extend the reader-connected gate to OPUS Arm and Read Memory (`MainActivity`)

- The `requireRfidConnected()` gate added in 2.4.9 now also covers **OPUS Arm** (`onArmClick`) and **Read Memory** (`onReadMemoryClick`) in both adapter listeners — the full set of reader-only tag actions is now gated. The Read Memory gate sits after its NFC-row branch, so NFC NDEF viewing still works without a reader.
- Verified on-device (TC22R): with the reader disconnected, tapping OPUS Status shows "Connect an RFID reader to use this." instead of running.

### Build

- **Version bump**: `2.4.9` → `2.4.10` (versionCode 100 → 101).

## [2.4.9] - 2026-06-27

### Changed — reader-only tag actions gated on the RFID radio being connected (`MainActivity`)

- The following tag actions now require a connected UHF reader and show "Connect an RFID reader to use this." (instead of failing mid-operation) when none is connected: **OPUS Status, Read Logger, Locate, Modify Memory, EM4425 Read DPP URL, EM4425 Write DPP URL**.
- Implemented via a single `requireRfidConnected()` gate (`rfidService.isConnected()`), applied at the shared action methods (`launchLocate`, `readEm4425UrlAndOpen`, `writeEm4425DppUrl`) and at the `onReadLoggerClick` / `onOpusStatusClick` / `onModifyMemoryClick` callbacks in both adapter listeners — so it covers the inline detail-panel buttons and the row pop-up menu alike. The OPUS Status gate sits after the NFC-row branch, so viewing an NFC tag's NDEF data still works without a reader.
- Consistent with NFC / camera independence: in those modes the radio is not connected, so these reader-only actions are blocked while the rest of the app stays usable.

### Localization

- New string `rfid_required_for_action` translated to all 7 non-English locales.

### Build

- **Version bump**: `2.4.8` → `2.4.9` (versionCode 99 → 100).

## [2.4.8] - 2026-06-27

### Fixed — TC22R RFID never connects after a reboot until the app is quit (`services/RfidService`, `utils/Re40BootRecovery`, `utils/BootReadiness`, `MainActivity`)

Reworks the 2.4.7 boot-readiness approach after on-device investigation on the TC22R. See `docs/RE40-BOOT-RECOVERY.md` for the full root-cause writeup and captures.

- **Root cause**: the integrated **RE40 radio is not connectable until ~3-4 min after boot** (variable, observed 240-320 s — far longer than "a minute more than Android"). The **first `connect()` attempted before then poisons the whole app process**: the broken session is held inside the separate `com.zebra.rfidhost` system service, keyed to our process, and is only released when our process *dies* (its `registerProcessDeath` fires). Proven on-device: the same process never reconnects (even after `Readers.Dispose()`/recreate), while a fresh process connects instantly — which is why "quit the app" was the only fix.
- **2.4.7's in-process retry/recreate is removed** — it cannot work (the poison lives in another process keyed to ours). Android 14 also blocks a silent background relaunch-to-foreground, so silent auto-restart was dropped too.
- **New behaviour**: when a connect on the integrated reader fails with `RFID_COMM_OPEN_ERROR` inside the post-boot window, `RfidService` no longer surfaces a confusing "connection failed". It raises a dedicated `WAITING_FOR_RE40` state, and `MainActivity` shows a **persistent countdown dialog** ("RFID radio starting up — ready in ~N s") anchored to `SystemClock.elapsedRealtime()` (survives app restarts), with a **Close app** button that kills the process so the user's reopen starts a clean one.
- **`utils/Re40BootRecovery`**: learns the per-device RE40-ready time from successful connects and persists it, so the countdown estimate improves over time. `utils/BootReadiness.BOOT_WINDOW_MS` widened to 420 s to cover the observed variance.
- **Robustness**: a state flag (`RfidService.isWaitingForRe40()`) is reconciled by `MainActivity` on resume / service-connect, so the countdown still appears if the live broadcast is missed during the boot-time receiver-registration race (and after the lock screen is dismissed).

### Fixed — DataWedge barcode/imager not ready right after a reboot (`barcode/DataWedgeReadiness`, `barcode/BarcodeIntegration`, `MainActivity`)

- DataWedge can also be unavailable for the first ~minute after boot (it reports `READY`/`ENABLED` ~48 s post-boot). Unlike the RE40 it does **not** poison the process, so no reopen is needed — but a scan attempted too early would silently do nothing.
- `DataWedgeReadiness` now **learns and persists** the DataWedge-ready time and exposes `isLikelyReady()` / `remainingMs()`. When a DataWedge barcode/imager feature is invoked too early (`startBarcodeInventory`, multimodal "scan with imager", "1D/2D by imager", "barcode + photo"), `MainActivity` shows a **non-blocking** notice ("Scanner still starting up — ready in ~N s"). It also now accepts `ENABLED` (not only `READY`) as ready — the value the TC22R returns.
- **NFC mode and camera (OCR / QR) modes are untouched** — they never depend on DataWedge or the integrated RFID, and none of the readiness gating runs for them.

### Localization

- New strings (`re40_wait_*`, `reader_error_re40_initializing`, `dw_starting_notice`) translated to all 7 non-English locales.

### Build

- **Version bump**: `2.4.7` → `2.4.8` (versionCode 98 → 99).

## [2.4.7] - 2026-06-27

### Added — boot-readiness waiting for the RFID subsystem and DataWedge (`utils/BootReadiness`, `barcode/DataWedgeReadiness`, `services/RfidService`, `barcode/BarcodeIntegration`)

- **Problem**: right after a reboot (or an auto-launch on boot) the integrated RFID radio service (QC_SERIAL) / Bluetooth stack and DataWedge are not up yet. Connecting in that gap fails — not because no reader is present, but because the subsystem isn't ready — and a DataWedge `SET_CONFIG` sent into the gap is silently dropped, so the barcode/imager trigger never works until the user backs out and re-enters the screen.
- **`utils/BootReadiness`** (new): single source of truth for "did the device boot recently", using `SystemClock.elapsedRealtime()` (monotonic since boot, immune to wall-clock changes / deep sleep). `BOOT_WINDOW_MS = 90s`; exposes `withinBootWindow()`, `sinceBootMs()`, `remainingBootWindowMs()`. Inside the window transient failures are treated as "still coming up"; once it closes, behaviour reverts to today's fail-fast so a genuine no-reader still reports promptly.
- **`barcode/DataWedgeReadiness`** (new): `awaitReady(ctx, cb)` queries DataWedge via the `GET_DATAWEDGE_STATUS` API and listens on `RESULT_ACTION` for `RESULT_GET_DATAWEDGE_STATUS`. Calls back the instant DataWedge reports `READY`; while inside the boot window it re-queries every 2 s (the service often doesn't answer at all until it's up). Falls back to `onReady(false)` promptly when DataWedge is absent, reports `DISABLED`, or the window closes — so it never blocks forever and non-Zebra hardware is a no-op.
- **`services/RfidService`**: applies to **all** connection attempts (auto-connect and manual). `searchForReaders` now uses `enumerateReadersWaitingForBoot()` — an empty enumeration inside the boot window retries with 1 s→5 s backoff instead of reporting "no readers". The connect retry loop, once the normal 3 attempts are exhausted, re-broadcasts `CONNECTING` (no error flash) and retries with 2 s→8 s backoff while inside the boot window. The region-not-set and charging-gate branches are untouched — those user-actionable prompts still surface immediately.
- **`barcode/BarcodeIntegration`**: `ensureProfileIfNeeded()` now defers the profile `SET_CONFIG` until `DataWedgeReadiness` confirms `READY` (or the window closes), fixing the silently-dropped-config-after-reboot case. On a normal launch DataWedge answers in tens of ms, so there's no perceptible delay.
- **`CodeImagerActivity` left as-is** — it is already fully event-driven (waits for `WORKFLOW_ENABLED` before firing, with a 24 s fallback), so it already tolerates a slow/booting DataWedge.

### Build

- **Version bump**: `2.4.6` → `2.4.7` (versionCode 97 → 98).

## [2.4.6] - 2026-06-25

### Fixed — OPUS Status / Read Logger lost OCRSSI + instantaneous temp/battery on the TC22R (`utils/ReaderCapabilityUtils`)

- **Regression from 2.3.4** (`40757ad`): the STATE_AWARE-capability fix disabled the OPUS measurement PreFilters (`temperatureFilter`/`batteryFilter`/`onChipRssiFilter` in `RfidInventoryManager`) for **all** integrated transports — which also killed the TC22R's on-demand measurement, so OPUS Status / Read Logger showed `OCRSSI 0` and a stale temperature (`word2=0xFFF0` → −0.06 °C), no battery.
- **Root of the over-reach:** the SDK capability flags are unreliable — verified on-device, the **TC22R reports `isRSSIFilterSupported()=false`** even though its radio *does* honor the filters. So the per-reader gate now uses the **model string** (the reliable signal): `rawModel` starting `TC22` (e.g. `TC2205-…`) → STATE_AWARE **on**; the TC701 QC radio (generic `QC` model) and EM45 (`RE_SERIAL`) stay **off**; true sleds (BLUETOOTH) stay on. The actual `isRSSIFilterSupported` / `isTagInventoryStateAwareSingulationSupported` / `maxNumPreFilters` are now logged on connect for visibility.

### Fixed — MQTT temperature precision (`managers/MqttManager`, `mqtt/MqttResponsePublisher`, `mqtt/MqttCommandExecutor`)

- **Temperatures were published with float-precision noise** (e.g. `21.190000534057617`). Every MQTT temperature is now rounded to **2 decimals** (`21.19`) and battery to 3 — at the SCAN/MULTIMODAL realtime block, the logger samples, the READ-response samples, and the command-scan reply. Matches the on-device `TemperatureCalculator` convention.

### Changed — Read Logger Status screen (`res/layout/dialog_read_user_memory.xml`, `helpers/OpusReadDialogHelper`)

- **Temperature card** compacted — label + value + read-count on **one line** (was two, mostly dead space), freeing vertical space.
- **Alarm violation log** is now a bounded (120 dp) **scrollable** area so a long violation summary scrolls in-card instead of pushing the rest of the status off-screen.

### Build

- **Version bump**: `2.4.5` → `2.4.6` (versionCode 96 → 97).

## [2.4.5] - 2026-06-24

### Changed — multimodal "(1D/2D) + photo" (`res/layout/dialog_multimodal_scan.xml`, `helpers/MultimodalScanDialogHelper`)

- **Renamed** the "Scan barcode + photo" button to **"(1D/2D) + photo"**.
- **Added its own imager-light (sun) toggle**, matching the "1D / 2D by imager" row. Both sun buttons drive the **same** imager illumination setting (both rows use the same `QDatDroidImagerCode` profile) and **stay visually in sync** — tapping either flips the LED flood (`toggleIlluminator` restyles every sun icon + rebuilds the profile so it takes effect on the next capture).

### Build

- **Version bump**: `2.4.4` → `2.4.5` (versionCode 95 → 96).

## [2.4.4] - 2026-06-24

Multimodal imager hardening on the TC701, a new "Scan barcode + photo" capture, and images now ride INSIDE the MQTT payload as base64 (no cloud upload).

### Added — "Scan barcode + photo" (`CodeImagerActivity` photo mode, `MainActivity`, `helpers/MultimodalScanDialogHelper`)

- **New "Scan barcode + photo" button.** Opens `CodeImagerActivity` in photo mode (`EXTRA_WITH_PHOTO`): the viewfinder comes up, the operator frames the item and **pulls the hardware trigger**, and `free_form_capture` returns the decoded barcode AND a captured frame in one result. On the TC701 AC670 imager the frame is `Y8` grayscale (1280×816); the chunked content-provider blob (`raw_data` + `next_data_uri`) is reconstructed via the restored `WorkflowImageDecoder`, JPEG-saved, and both attach to the bundle (`codes[]` + `images[]`).

### Changed — images EMBEDDED in the MQTT payload as base64 (`utils/MultimodalImageEncoder`, `MainActivity.addImageToScan`, `managers/MqttManager`, `models/MultimodalScan`)

- **Multimodal images no longer upload to the QDat.io cloud API** (which requires a login that often isn't configured). Each image is downscaled to fit a **640×480** box (aspect preserved), converted to **grayscale**, JPEG-compressed (q50) and **base64-encoded** — a few KB — then embedded in the MULTIMODAL `images[]` as `encoding:"base64"` + `data:<base64>` with `upload_status:"embedded"`. `MultimodalImageEncoder` is the new encoder; `addImageToScan` drops the cloud upload. Applies to both "Add image" and "Scan barcode + photo".

### Fixed — TC701 imager startup + decode (`CodeImagerActivity`)

- **Event-driven capture state machine.** The hands-free imager was firing `START_SCANNING` on blind timers — before the workflow enabled (rejected `WORKFLOW_IN_INVALID_STATE`) and DURING a live capture (aborting the in-flight decode → "beeped but no data"). Triggers are now purely event-driven: nothing fires until `WORKFLOW_ENABLED`/`SESSION_STARTED`, and `CAPTURING_STARTED` cancels the watchdog so a live capture runs to its decode.
- **~Halved startup.** Profile creation moved into `prewarmProfile` (overlaps the dialog) so `onCreate` skips a redundant ~11s `SET_CONFIG`; a config-version gate rebuilds the persisted profile once when its config changes. `session_timeout` 16000→2000 so decodes deliver fast.

### Changed — "Add RFID tag" wake-up (`MainActivity.captureRfidIntoScan`)

- **"Add RFID tag" now runs a wake-up sweep before picking up the tag.** Instead of a fixed 1.3 s inventory, it keeps the inventory running up to `MM_RFID_WAKEUP_MS` (4 s) — polling until the wanted tag appears (the scanned barcode's EPC, else any fresh tag) then stopping early — so a sleeping / weakly-coupled OPUS tag has time to energize and backscatter before it's attached (the 1.3 s window clipped them, same reason `runWithSelectWakeUp` gives a read target up to 6 s).

### Changed — multimodal dialog (`helpers/MultimodalScanDialogHelper`)

- **Font scale capped at 1.0** for the dialog so a large device font setting (e.g. a TC701 at 1.3×) no longer wraps the buttons and pushes "Close" off-screen.
- **Imager illuminator toggle** (sun button) on the "1D / 2D by imager" row — `BarcodeTrackerModule` `illumination` on/off, persisted, rebuilds the profile on toggle.
- Publish toast counts TOTAL captures (every terminal line) via `MultimodalScan.totalCaptures()`.

### Removed

- **"Add image" button** (the standalone color-camera photo). Photos now come via "Scan barcode + photo"; the dead `launchMultimodalImageCapture` / `REQUEST_MM_IMAGE` path and the camera-photo `onAddImage` host callback are gone. (`addImageToScan` stays — it's how the imager photo attaches.)

### Docs

- `docs/MULTIMODAL-PAYLOAD.md` + `docs/sample-payloads.json`: images are now base64-embedded (`encoding`/`data`/`upload_status:"embedded"`); decode `data` back-end-side, no HTTP upload.

### Build

- **Version bump**: `2.4.3` → `2.4.4` (versionCode 94 → 95).

## [2.4.3] - 2026-06-24

Multimodal imager/OCR overhaul — "1D / 2D by imager" now shows the on-screen camera viewfinder, decodes hands-free, and labels codes 1D/2D; OCR moved fully in-app; and the assembly dialog got a terminal-style capture log. Rolls up the intermediate 2.4.2 (no separate release notes).

### Changed — multimodal imager capture (`CodeImagerActivity`, `MainActivity`, `helpers/MultimodalScanDialogHelper`)

- **"1D / 2D by imager" → DataWedge `free_form_capture` with a live camera viewfinder + BarcodeTracker decode/highlight**, replacing the old in-place `ImageCaptureIntegration` scene path (and a short-lived bare BARCODE-plugin aimer that had no preview). `CodeImagerActivity` is self-contained; `BaseFreeFormImagerActivity` removed.
- **Hands-free auto-capture.** The soft trigger is pumped — `TOGGLE_SCANNING` opens the session, then `START_SCANNING` is re-issued every ~1.8 s until a decode — so no hardware-trigger press is needed. The "point the imager…" hint screen is gone.
- **1D/2D dimension from the workflow `barcodetype`.** `free_form_capture` returns a numeric `barcodetype` (not `label_type`); mapped to dimension + symbology (`3` = Code128 → 1D, `28` = QR → 2D; unknown logs + defaults 1D). Codes land in `codes[]` with `source:"imager"`.
- **Imager LED flood is user-toggleable.** A sun button on the imager-selector row toggles the `BarcodeTrackerModule` `illumination` param (`on`/`off`), persisted in `AppSettings/mm_imager_illumination` (default lit). The profile is recreated on toggle so it takes effect immediately.
- **Profile handling.** The imager profile is pre-warmed when the dialog opens; stale free-form / BARCODE profiles that would win the activity association (delivering a label-less result → "? code") are deleted on prewarm; the own profile is purged on prewarm + recreated so config changes (illumination) apply.

### Changed — OCR is now in-app (`OcrCameraActivity`)

- **OCR runs on CameraX + Google ML Kit on-device text recognition**, not DataWedge `free_form_ocr` (Zebra-license-gated — "invalid workflow license" unless the app is EB-signed). Live recognized-text preview + Capture-to-confirm so the operator reviews the text before publish; `ocr[]` `source` is now `"camera"`. The in-place `OcrIntegration` / `ImageCaptureIntegration` / `WorkflowImageDecoder` were removed.

### Changed — assembly dialog (`helpers/MultimodalScanDialogHelper`, `models/MultimodalScan`)

- **Terminal-style capture log.** The summary at the top of the multimodal dialog is a fixed-height (96 dp) scrollable emerald-on-black monospace "terminal": new captures flash bright green, only the box scrolls (not the dialog), buttons compacted so "Add RFID" stays visible.
- **Publish toast counts TOTAL captures** (every terminal line) via `MultimodalScan.totalCaptures()`, was distinct modality types.

### Docs

- `docs/sample-payloads.json` regenerated (standalone fixtures; adds a `codes[] source:"imager"` example + `ocr source:"camera"`); `docs/MULTIMODAL-PAYLOAD.md` updated.

### Build

- **Version bump**: `2.4.1` → `2.4.3` (versionCode 92 → 94).

## [2.4.1] - 2026-06-21

Multimodal scan — bundle an RFID/NFC read together with 1D/2D codes, captured images, and spatiotemporal (GPS + timestamps) into ONE MQTT message so the back-end never has to collapse separate per-modality entries — plus a barcode/QR inventory mode that runs continuously on the device imager when no RFID reader is connected.

### Added — Multimodal scan (`models/MultimodalScan`, `helpers/MultimodalScanDialogHelper`, `MainActivity`, `managers/MqttManager`)

- **New `MULTIMODAL` MQTT payload (schema v1) on the existing `/data` topic.** A self-contained bundle carries a stable `scan_id` (back-end upsert key, distinct from the per-publish `command_id`), an `anchor` (whichever mode was isolated first), `spatiotemporal` (GPS with new `accuracy_m` + `gps_time` + `tz_offset_minutes`), and `rfid` / `nfc` / `codes[]` / `images[]`. Published via `MqttManager.publishMultimodalScan` reusing the SCAN wrapper, outbox (keyed by `outbox_key` so the existing `tag_registered` drain works unchanged), license gate and offline replay. Full contract in `docs/MULTIMODAL-PAYLOAD.md`.
- **Any-anchor assembly UX.** Isolate any one mode first (RFID/NFC via a tag-row **Multimodal scan** action, or a code/image via the toolbar **New multimodal scan**), then add the others in any order: **Add 1D/2D code (camera)**, **Add image**, **Add RFID tag**. Hardware (DataWedge) barcode scans and NFC taps fold into the open bundle automatically.
- **Images are references, not bytes.** The JPEG is uploaded over HTTP via the existing photo path; the bundle carries `image_id` + `url` + `sha256` + dimensions + `bytes` + `upload_status` only.
- **Code dimension/symbology.** Camera captures (ZXing) report `dimension` (1D/2D) + `symbology`; hardware (DataWedge) captures now report them too — `BarcodeIntegration` reads the DataWedge `label_type` and normalizes it to the same ZXing-style vocabulary (`LABEL-TYPE-QRCODE` → `QR_CODE`), so hardware codes are labeled 1D/2D + symbology instead of being unclassified. `QrScannerActivity` returns `EXTRA_QR_FORMAT`/`EXTRA_QR_DIMENSION` (additive; legacy callers unaffected). The assembly dialog hints how to enable the barcode trigger when it's off.
- **DataWedge soft scan trigger.** The assembly dialog offers a **"Scan with device imager"** action (shown when the barcode trigger is enabled) that fires the Zebra hardware imager via `BarcodeIntegration.softScanTrigger()` (`SOFT_SCAN_TRIGGER` / `TOGGLE_SCANNING`) — no camera-activity takeover; the decode returns through the existing receiver and attaches to the bundle as a labeled hardware code.
- **Hardware scene/document image (`images[]` `source:"imager"`).** New "Capture image (imager)" action fires DataWedge's `free_form_capture` WORKFLOW (`ImageCaptureIntegration`) to capture a scene/document image with the device imager — no camera app. The raw frame (YUV/Y8) arrives via DataWedge's content provider (chunked `next_data_uri`), is reconstructed + JPEG-encoded off the main thread (`WorkflowImageDecoder`, ported from `FreeFormImageCaptureSample1`), then uploaded via the existing photo path and attached to `images[]` with `source:"imager"`. Same separate-profile switch/restore pattern as OCR. Adds `com.symbol.datawedge.permission.contentprovider`. <em>Needs on-device validation.</em>
- **OCR text modality (`ocr[]`).** New "Capture text (OCR)" action fires DataWedge's `free_form_ocr` WORKFLOW (`OcrIntegration`) to read printed text (serials, lots, labels) with the imager. It uses a separate unassociated `QDatDroidOCR` profile, switched to for a one-shot capture and switched back to the barcode profile on result/timeout/pause so normal scanning is always restored. The recognized text attaches to the bundle's `ocr[]` block and can be the scan anchor (`anchor.modality":"ocr"`). Modelled on the official `FreeFormOCRSample1`. <em>Requires the `free_form_ocr` workflow in the device's DataWedge build; needs on-device validation.</em>
- **`createTagDataJson` refactor:** the SCAN per-tag field emission is extracted into `buildRfidBlock` and reused verbatim by the multimodal `rfid{}` block — one funnel, identical wire vocabulary. New `mm_*` strings translated across all 8 locales. MQTT-gated (`Features.mqtt()`).

### Added — barcode/QR inventory on the device imager (`MainActivity`, `barcode/BarcodeIntegration`, `managers/MqttManager`)

- **Start with no RFID reader connected runs a barcode/QR inventory on the built-in imager.** When `startInventory()` finds no reader and DataWedge is present it reroutes to `startBarcodeInventory()`: each distinct decoded code becomes a `BARCODE` row (dedup by value + read count, GPS snapshot), is recorded to history, and is published over MQTT via `MqttManager.publishBarcodeScan` (SCAN payload, `parser=barcode`, `tag_type=BARCODE`, symbology — reusing `publishNfcScan`'s outbox/dedup/GPS/topic). Mirrors `onNfcTagTapped`.
- **Scanning is automatic/hands-free with always-on illumination while the mode is armed.** `enableInventoryOutput()` sets the BARCODE plugin's **`beam_timer=60000`** so the imager stays lit (decoding) for up to 60 s per aim instead of timing out after ~5 s. `startBarcodeInventory` drives the standard imager (no full-screen viewfinder takeover) via `BarcodeIntegration.startScanning()` (`START_SCANNING`), re-issued on a periodic `armBarcodeScanPump` (10 s, well inside the beam window) and re-armed immediately after each decode, so the imager keeps scanning with no per-code trigger pull until **Stop**. STOP / `onPause` / any mode switch call `stopScanning()` + `endSession()`. (On-device param sweep of the TC58 INTERNAL_IMAGER: `beam_timer` 10000–60000 and `aim_type` 0–8 are all accepted, but only `beam_timer` affects illumination dwell — no `aim_type` auto-rescans after a decode on this imager, so it's left at the device default and re-scanning is driven by the re-arm. `beam_timer=0` and `aim_type=9` are rejected as *"Unsupported param value"*, and a rejected value triggers a multi-second scanner-reconfigure churn, so those are avoided.)
- **Imager illuminates immediately on Start (pre-warm).** The DataWedge `SET_CONFIG` (OVERWRITE) + profile switch take ~4 s to become active on this device, during which `START_SCANNING` is dropped — so Start used to wait several seconds before the aimer lit. `prewarmBarcodeInventoryProfile()` now does that setup ahead of time (on resume, while idle, when no reader is connected), Stop keeps the profile warm, and it's released on pause / reader connect (`teardownBarcodePrewarm`). Start → illumination dropped from ~4 s to ~0.5 s (just DataWedge's command-queue latency).
- **Decodes route to the app, not the EPC filter, when no reader is connected.** `enableInventoryOutput()` overwrites the `QDatDroid` profile with **KEYSTROKE output OFF + INTENT output ON** (broadcast to `com.meerv.qdat.SCAN`), applied only on the no-reader path — so codes are logged as inventory rows instead of being typed into the focused EPC search box. With a reader connected, the trigger still types into the EPC filter (unchanged).
- **Modes are mutually exclusive.** Entering a multimodal scan, an RFID reader connecting (both connect paths), or starting RFID inventory all stop barcode inventory first, so the shared DataWedge session is never torn down underneath a running mode and RFID + imager never run concurrently.
- New `barcode_inventory_started` string across all 8 locales.

### Fixed — DataWedge `SET_CONFIG` silently rejected (`barcode/BarcodeIntegration`)

- **Every `SET_CONFIG` was dropped by DataWedge** with *"Key PLUGIN_CONFIG expected ArrayList but value was a `[Landroid.os.Parcelable;`"* (`ClassCastException` in `ProfileSettings`), so the `QDatDroid` profile always ran on defaults — KEYSTROKE output **ON** (decodes typed into the focused EPC search box) and INTENT output **OFF** (our receiver never fired); the multimodal hardware-barcode path never worked either. `PLUGIN_CONFIG` is now passed via `putParcelableArrayList(ArrayList<Bundle>)` in both `ensureDataWedgeProfile` and `enableInventoryOutput` (`APP_LIST` stays a `Bundle[]`, which DataWedge accepts). `DELETE_PROFILE` now passes a `String[]` (was a `String` → *"expected String[]"*, so deletes silently failed and the profile leaked).

### Docs

- `docs/MULTIMODAL-PAYLOAD.md` (the MULTIMODAL schema v1 contract), `docs/sample-payloads.json` (fixtures for every payload variant), and the documented barcode/QR inventory SCAN payload.

### Build

- **Version bump**: `2.3.4` → `2.4.1` (versionCode 90 → 92).

## [2.3.4] - 2026-06-15

OPUS tag-locate brought up on the integrated readers (TC701/TC22R QC radio and EM45 RE40), a STATE_AWARE-capability correctness fix that was silently breaking OPUS reads on the QC radio, GPS/NFC indicator state handling, Read Logger flow polish, and connect/reader-selection dialog hygiene.

### Fixed — STATE_AWARE capability over-report broke OPUS on the QC radio (`utils/ReaderCapabilityUtils`)

- **`stateAwareFiltersSupported` was computed from transport as `!RE_SERIAL && !SERIAL`, so it returned `true` for the QC radio (TC701/TC22R)** — but those radios do **not** honor STATE_AWARE Select filters (air-protocol caps report `support_RSSI_filters=false`). The wrongly-true flag made the EPC-only inventory apply OPUS STATE_AWARE filters the radio can't honor, mis-configuring it and leaving OPUS tags effectively unreadable. Now **all integrated transports** (`RE_SERIAL` / `SERIAL` / `QC_SERIAL` / `SERVICE_USB` / `SERVICE_SERIAL`) report `STATE_AWARE=false`; only true sleds (`BLUETOOTH`) keep it. `docs/READER-RADIO-MATRIX.md` updated with the STATE_AWARE row + history note.

### Added — OPUS emulated locate on the QC radio (`TagLocateHelper`, `MainActivity`)

- **Tag Locate now tracks OPUS tags on the QC radio (TID-only path).** Clear-list flush + re-acquire before locate so we never locate a ghost; a ~5 s periodic inventory restart (`opusLocateRefresh`) re-singulates the tag; and a **12 s flat-hold + raw (no-EMA) curve + solid proximity-bucket area fill** so the sparse ~5 s reads draw a steady, readable curve. All gated on `qcRadioLocate` (transport), **not** `emulatedLocateMode` — EM45 and RFD40 keep their original chart rendering. Hold values are set in `processReading` (not `updateUIWithLocation`, which can early-return) so the hold gate never fails.

### Fixed — EM45 RE_SERIAL radio not recognised as integrated (`utils/ReaderCapabilityUtils.isIntegratedConnected`)

- **The EM45's on-SoC RE40 enumerates over `RE_SERIAL`, which was missing from `isIntegratedConnected()`.** The two halves of Tag Locate disagreed: `launchLocate()` saw `integrated=false` and took the sled path (never starting the fast piggyback inventory), while `TagLocateHelper` saw `isTagLocationingSupported()==false` and went EMULATED (which never starts its own inventory) — neither kept an inventory running, so locate got zero reads and sat on "No signal" forever. Added `RE_SERIAL`/`SERIAL` to `isIntegratedConnected()` so both halves agree and `launchLocate` starts `startLocateFastInventory()`. Purely additive — TC701 (`QC_SERIAL`) and TC22R (`SERVICE_USB`) already returned true; RFD40/RFD90 sleds use `BLUETOOTH` and stay on the native path. Verified on-device (EM45). See `docs/EM45-RE40-LOCATE-INTEGRATED-DETECTION.md`.

### Fixed — OPUS EPCs ending in zero were truncated (`models/TagInfo.normalizeEpc`)

- **`normalizeEpc` stripped trailing zero nibbles**, so an OPUS tag with EPC `5201F25100009200` was stored/displayed as `5201F251000092`. OPUS repurposes the Gen2 PC word for sensor state (reads `0x0000`), so the PC-aware overload fell back to the lossy single-arg strip. An EPC is whole 16-bit words — trailing zero nibbles are real data; only whole `0000`/`FFFF` words are padding. Both `normalizeEpc` overloads are now lossless: a valid PC word is authoritative (trim, no strip); with no usable PC word, strip only whole padding words. The two **must** agree because lookups do `tagsMap.get(getDisplayEpc())` against keys stored as `normalizeEpc(epc)` — a divergence silently missed (it broke the Read Logger wake-up: "tag never seen"). Tests updated to the lossless contract with a regression for `5201F25100009200` and the key/display invariant. See `docs/EPC-TRAILING-ZERO-TRUNCATION.md`.

### Fixed — EM45 / integrated RE_SERIAL radio read reliability (`managers/TagMemoryManager`, `OpusReadDialogHelper`)

- **`readTagMemoryViaEvent` now settles (`stopAccess` + `Inventory.stop` + sleep) and retries once on `InvalidUsageException`**, so back-to-back per-bank reads (TID→USER→EPC) land instead of colliding.
- **Read Logger wake-up:** if **no** tag is read at all by ~2.5 s (wedged radio), the inventory restarts once before declaring "out of range".
- **OCRSSI / instant temperature / battery now show "N/A"** instead of a perpetual "Starting…"/placeholder on the integrated radio: those live values require STATE_AWARE PreFilter Selects the integrated radio can't issue (`supportsOnChipSensorMeasurement() == false`). The OCRSSI auto-power-cap is already inert there (needs `ocRssi > 0`).

### Changed — Read Logger flow (`OpusReadDialogHelper`, `res/values*/strings.xml`)

- **Two-phase prep is clearer.** The wake-up and reading steps are now one consistent panel labelled by step — "Step 1 of 2 · Waking the tag…" → "Step 2 of 2 · Reading…" — the cancel button was dropped from the prep box (renamed "Inventorying…"), and the read progress is plain "Reading …%". The wake-up box is widened to match the reading dialog so it no longer jumps size. The dialog now opens straight on the **Logger Data** tab and **Configuration → Config**. Translations updated across all 8 locales.

### Added / Fixed — GPS & NFC status indicators (`MainActivity`)

- **GPS indicator was never going green.** `gpsStatusText`/`gpsStatusIndicator` were never bound (always null), so the null-guard in `updateGpsStatus` returned early on every GPS-enabled tick. The real `gps_status_text` view is now bound and only the icon is required: the icon goes **green the moment GPS is enabled**, with a blinking **"Fixing"** sub-label until a fix then steady **"Fixed"** (no longer waits for a fix to turn green). Added sub-labels: amber **"No GPS"** when the device has no location provider, and **"Override"** when a manual location is pinned. "Merely disabled" states (app off / OS off / no manager) render **grey, not red**.
- **`onResume` now reconciles `GpsManager` with the `gps_enabled` flag**, lazily creating/starting/wiring it when re-enabled — fixes re-enabling GPS from settings doing nothing when the app launched with GPS off.
- **NFC indicator** is now grey when inactive, green in NFC mode (was red when inactive).

### Changed — asset tools available on trial / MQTT (`utils/Features`, `MainActivity`)

- **`Features.cloudApi(Context)` also returns true when MQTT is enabled** (`mqtt_enabled`), so the **Asset Picture / Show / Modify** buttons appear once MQTT is on (and during trial via `DemoEntitlement`), not only on paid tiers. The inline bar refreshes on resume; the MQTT settings tab is reachable whenever MQTT is available (trial/flavor).

### Fixed — connect & reader-selection dialogs (`MainActivity`, `services/RfidService`)

- **Cancel now actually aborts the in-progress connect.** Hitting Cancel on the connecting dialog used to let `RfidService`'s connect loop grind through all `MAX_CONNECTION_RETRIES` — each Bluetooth attempt to an absent reader blocks on its own multi-second timeout, leaving the radio busy and re-scanning laggy. `cancelConnect()` now sets a volatile flag the retry loop checks and disconnects the reader on a side thread to break the blocking `openConnection()`; a cancelled attempt returns a clean `DISCONNECTED` (no error toast / retry machinery) so the UI goes straight back to reader selection. Wired from both connecting-dialog cancel paths.
- **Stacked reader-selection dialog.** `showReaderSelectionDialog()` overwrote the `readerSelectionDialog` reference without dismissing a dialog already on screen — called more than once (initial scan + a re-show after a failed Bluetooth-sled attempt), two dialogs stacked and tapping a reader dismissed only the latest, orphaning the earlier one. Now any showing selection dialog is dismissed before building a new one.
- **Reader-selection menu dismisses on the CONNECTED event**, so a connect that completes via auto/fallback (e.g. TC701 after a failed Bluetooth sled attempt) no longer leaves the picker orphaned.

### Changed — antenna power lifecycle (`MainActivity.updateAntennaPowerFromSettings`)

- **Antenna power is no longer re-applied during a scan or locate.** `updateAntennaPowerFromSettings` now skips while inventory/locate is running — power applies only on transitions or via the slider (which already stop→apply→restart).

### Added — locate debug overlay (`TagLocateHelper`, `MainActivity`)

- **Swipe-right debug-log overlay added to the Locate dialog**, mirroring the Read Logger dialog; gated by the **App → General** debug switch.

### Build

- **Version bump**: `2.3.3` → `2.3.4` (versionCode 89 → 90).

## [2.3.3] - 2026-06-14

Detailed-list (table) view default tuning so the most-used columns fit and read cleanly on the narrow TC701/TC22 screens without manual resizing.

### Changed — table view defaults (defaults version 3)

- **Status column now hidden by default** in both the adapter (`showColumnStatus`) and MainActivity's column-visibility load (`table_col_status` default → `false`). Default visible columns are now **EPC, RSSI, Count**.
- **EPC column widened** to **203 dp** (was 147) so full EPCs fit on one line at the new font; **RSSI 65 dp** (was 50) and **Count 50 dp** (was 49).
- **Base table font reduced to 9 sp** (was 10).
- New **`TABLE_DEFAULTS_VERSION = 3`** migration applies these (font, EPC width, RSSI/Count widths, Status hidden) once over existing installs — the user can still resize/toggle afterward and it sticks until the next bump.

## [2.3.2] - 2026-06-14

Locate-screen usability pass following the 2.3.1 emulated-locate work: a tighter radar/scale tuned to the readable signal band, an audible proximity beep, and the scanning beeper volume surfaced as a proper radio control.

### Changed — locate radar & proximity scale

- **Radar rescaled to the usable band.** Center is now **−30 dBm** and the outer edge **−70 dBm** (was −20…−90), with **4 rings at −40 / −50 / −60 / −70 dBm** — the same thresholds as the Very Close / Close / Medium / Far proximity zones. Signals below −70 dBm (not reliably readable) are clamped to the rim instead of wasting radius.
- **Proximity bar/chart scale** (`rssiToPercentage`) floor moved from −90 to **−70 dBm** (−70 → 0%, −30 → 100%) so it matches the radar and the bar actually fills over the readable range.

### Fixed — locate proximity beep was inaudible

- The locate proximity beep used `ToneGenerator` on **`STREAM_NOTIFICATION`**, which is routinely silenced on enterprise devices (notification volume / DnD) — so the beep never played even with the in-dialog "Beep" box on. Moved to **`STREAM_MUSIC`**. (This is the phone-side cue; it is independent of the reader's hardware beeper that the overflow "No sound" toggle controls.)
- Removed a redundant "very close only" beep call that double-triggered the tone at close range; the cadence beep (faster as you approach, at all levels) is now the single path.

### Changed — scanning beeper volume control

- **General RFID settings → Beeper Volume** is now a **4-button radio group** (Quiet / Low / Medium / High) instead of a dropdown spinner. Same persisted setting (`RFIDGeneralSettings/beeper_volume`), applied to the reader on Save and on every connect, with the same Do-Not-Disturb audibility warning.

## [2.3.1] - 2026-06-13

Tag-locate ("hot/cold" proximity) now works on the **TC701/TC22R integrated radio**, where the Zebra Tag Locationing API reports unsupported and state‑aware Select filters are unusable. The **RFD40/RFD90 sled locate path is unchanged.**

### Fixed — emulated tag-locate on the integrated radio

- The integrated Qualcomm radio reports `isTagLocationingSupported() == false`, so the dedicated locationing API is unavailable, and a state‑aware EPC Select PreFilter either fails (`~2 s` block, then `null`) or returns **zero reads** while wedging the command channel. Locate previously showed no signal (or a value that only ever decayed) on these devices.
- **New emulated-locate path:** for the integrated radio, locate now switches the inventory to a fast **EPC‑only `Inventory.perform()` in `SESSION_S0`** (no per‑tag TID read) for the duration of the dialog, so a stationary tag re-reads at full rate instead of every few seconds. Proximity/RSSI is derived in software from the live read stream and the normal TID inventory is restored on exit.
- Locate setup reader I/O moved **off the UI thread**, eliminating the ~2 s × 2 `Davey` jank when the dialog opened.

### Fixed — false "reader unresponsive" / auto-reconnect on the integrated radio

- On the integrated radio, `stopAccess()` / `Inventory.stop()` routinely return `RFID_COMM_SEND_ERROR` ("failed: 20") when there is nothing to stop. `forceReleaseSdkLock` counted these as recovery failures, falsely declared the reader unresponsive, and triggered a disconnect/reconnect mid-operation. These are now treated as benign no‑ops **(integrated radio only)**; the genuine wedge signal (`RFID_API_COMMAND_TIMEOUT`) still triggers recovery.

### Changed — locate UI

- Chart **"Peak NN%"** label now renders **below** the green peak line so it is never clipped at the top of the plot.

### Docs

- Added `docs/qualcomm-rfid-locationing-issue.md` — a write-up for Qualcomm R&D of the integrated-radio locationing / state‑aware‑Select / inventory‑refresh limitations and the workaround.

## [2.3.0] - 2026-06-13

On-device TC701 + RFD4031 hardening: a working factory-reset region+channel wizard, an in-app browser, and a full unification of the tag-action buttons across card / list / inline surfaces.

### Added — factory-reset region + channel wizard (end-to-end)

- A factory-reset reader now connects **with a region+channel picker** instead of silently defaulting. On `RFID_READER_REGION_NOT_CONFIGURED` the service enumerates `SupportedRegions`, the UI shows a region spinner (all regions) + a per-channel checkbox list (interactive per `isChannelSelectable()`), and on Apply the chosen region/channels are written **on a fresh handle inside the connect flow** and committed with `saveConfig()`. Because the first region write reboots the reader, the app shows a **"reader rebooting — reconnecting in ~30 s"** notice and reconnects automatically.
- **In-app Factory Reset Reader** action (Reader Info → General, model-gated).
- Regulatory tab: **expandable per-channel selection** + an **Advanced channel-lock override** (confirmation-gated; for when the reader reports the region's channels as fixed).
- RF Modes: fixed the **Mode-N apply failure** (`OperationFailureException`) by writing a valid TARI for the selected mode; labels shortened to **Auto** / **Refresh**.

### Added — in-app browser

- Tag NDEF/DPP URLs open in a built-in **WebView with a "Return to QDatDroid" bar** at the bottom (JS + zoom; web-back; open-in-system-browser), instead of handing off to the system browser. URLs are underlined, clickable links in both card and list views.

### Changed — unified tag actions (one source of truth)

- The card, the detailed-list bottom sheet, and the inline single-tag bar now all render from **`buildActionEntries()`** — identical **buttons, names, colours, icons and conditions** everywhere. Card buttons **wrap onto multiple rows** (new `FlowLayout`) so every action is visible. Includes Asset picture / **Show Asset / Modify Asset**, OPUS Status, Read Logger, OPUS Arm, Locate, Read Memory, Modify Memory, Open URL, **EM4425 Read DPP URL** (brown) / **EM4425 Write DPP URL** (purple). **Isolate** is fluorescent-green with auto-contrast text.

### Added / fixed — reader identification & dialogs

- Reader rows show the **Bluetooth pairing name / serial / MAC** (the SDK returns the MAC as serial pre-connect), a ⭐ for previously-used readers, and a scrollbar; dropped "Built-in".
- **Unrecognized-reader** warning; **connected-reader/disconnect dialog** restyled to match the connect screen with full detail + serial. Region/unknown dialogs moved off the transparent `RoundedDialog` theme.
- Tag-type badge: **"EM EM4425" → "EM4425"** (doubled short-name).

### Added — misc

- **"No sound"** overflow toggle mutes scanning beeps live (stops/restarts inventory so it takes effect).
- Locate screen: linear-only proximity axis, Log button removed, **STOP** label, Radar/Chart labels.
- Card EPC **autosizes** to one line; inventory filter font reduced so the full EPC fits; NFC URL wraps to two lines without squishing.
- First-run trial gate moved off the splash → only on first MQTT engagement.

## [2.2.0] - 2026-06-13

Reader bring-up, identification, and regulatory configuration overhaul, driven by on-device TC701 + RFD4031 testing.

### Added — factory-reset region + channel picker (123RFID parity)

- **Factory-reset readers are now configured entirely in-app.** When a reader connects reporting `RFID_READER_REGION_NOT_CONFIGURED`, the app no longer silently defaults to Canada/USA — it surfaces an inline **region + channel picker** (`dialog_region_picker`) that mirrors Zebra 123RFID 2.0.5.275: a region spinner listing **all** `ReaderCapabilities.SupportedRegions` ("Name (Code)") plus a **per-channel checkbox list** rebuilt from the reader when the region changes. Channels are interactive only when the region reports `isChannelSelectable()`; otherwise they are shown checked + disabled.
- **In-app factory reset.** Reader Info → General gains a model-gated **Factory Reset Reader** action (`Config.resetFactoryDefaults()`), with a confirmation dialog and a clean teardown so the rebooting reader re-enumerates.
- The region write happens **inside the connect flow on a fresh handle** the moment `openConnection()` throws region-not-set — applying it later on a separate thread fails because the partial handle goes stale. The chosen region + channels are committed with `Config.saveConfig()` so they persist.

### Added — Regulatory tab per-channel selection

- The Regulatory settings tab replaces the display-only "channel set" spinner with an **expandable per-channel checkbox list** ("N of M channels"), pre-checking the reader's currently-enabled channels and writing the selected set via `setEnabledChannels()` + `saveConfig()` (previously it always enabled every channel).

### Added — reader identification & info

- **Reader selection rows** now show the Bluetooth **pairing name**, serial, and MAC on separate non-wrapping lines with an ultra-small type icon, a star for previously-used readers (`KnownReadersStore`), and a scrollable list. Fixes the SDK returning the **MAC as the serial** before connect (it was shown as "S/N").
- **Unrecognized-reader warning**: connecting to a Bluetooth reader never used on this install shows an advisory dialog (purely a heads-up; no lockout).
- **Connected-reader info dialog**: tapping the reader icon while connected now shows full detail (Bluetooth name, **serial**, MAC, connection, firmware, region) above the Disconnect button.
- Dropped "Built-in" from the integrated-reader label ("TC701 Built-in RFID" → "TC701 RFID").

### Added — quick mute

- **"No sound"** overflow-menu toggle mutes scanning beeps (sets `BEEPER_VOLUME.QUIET_BEEP`, the SDK floor — there is no true off), persisted and re-applied on connect.

### Added — reader diagnostics

- Optional reader debug logging (`ReaderDiagnosticsLogger`) — file (10 MB cap) + share sheet and logcat mirror, plus a debug-gated adb hook (`am broadcast -a com.meerv.qdat.DEBUG_READER_DIAGNOSTICS --ez enable true`).

### Fixed — dialogs

- The region and unrecognized-reader dialogs were built on `R.style.RoundedDialog`, whose transparent window background left the contents floating and unusable; both now use the opaque `MaterialAlertDialogBuilder`.
- The first-run lead/trial gate no longer fires from `onCreate` (it overlapped the splash) — it now appears only on first MQTT engagement.
- Inventory list: the NFC NDEF URL on the TID line is forced to a single line so it never wraps.

## [2.1.8] - 2026-06-10

### Fixed — Application Settings tab navigation (`ApplicationSettingsActivity`)

- **Tapping the "License" chip landed on "History" (and other tabs could drift to the last tab).** The chip→page sync set `isSyncingTabs = true`, called `viewPager.setCurrentItem(i, true)` (an **asynchronous smooth scroll**), then reset `isSyncingTabs = false` **synchronously** — so the guard was already off when the smooth scroll's intermediate `onPageSelected` callbacks fired. Those callbacks slipped through the guard, re-checked the passed-over chips, re-entered the chip→page handler, and drifted the page toward the last tab. With License at index 8 and History pinned last at index 9, selecting License consistently bounced to History. Fixed by making chip taps an **instant jump** (`setCurrentItem(i, false)`), so only the destination `onPageSelected` fires and there is nothing to drift. Verified on a TC701: tapping License now lands on `LicenseSettingsFragment` (pos 8). (The initial selection already used `setCurrentItem(initial, false)`; this makes chip taps consistent with it.)

## [2.1.7] - 2026-06-10

TC701 integrated radio: the app now recovers gracefully from the Zebra charging-gate that blocks the built-in reader, and explains the two distinct failure modes instead of always saying "unplug the cable".

### Fixed — charging-gate handling on the integrated reader (`services/RfidService`, `res/values*/strings.xml`)

- **Reader wouldn't connect while the device was on power, with a misleading message.** On TC701-class devices the Zebra `RFIDHostService` refuses to open the integrated radio's COMM port while the platform reports a charging source — `reader.connect()` throws `OperationFailureException` with `statusDescription = RFID_COMM_OPEN_ERROR` / vendor message *"Charging source connected"*. The gate lives in the host/firmware (it reads the Zebra `BatterySwap` HAL, `vendor.zebra.hardware.batteryswap@2.0::IBatterySwapDevice`), **below** the SDK — no `Config` call can bypass it, because `Config` requires an already-open connection. Diagnosed via the host-service cold-init trace (`RFIDHostService.onCreate` → binds `com.zebra.batteryswapservice` → emits "Charging source connected" before any radio bring-up).
- **Self-healing auto-retry.** `connectToReader()` no longer burns its 3 retries against a gate that can't change in the ~100 ms window. On a `CHARGING` failure it disconnects, stashes the `ReaderDevice` + `ConnectionCallback`, and arms an `ACTION_POWER_DISCONNECTED` receiver (`powerClearReceiver` / `armPowerClearRetry()`). The instant external power is removed it re-fires `connectToReader(...)` — the user doesn't have to come back and tap retry. The watcher is torn down on a successful connect (`clearPendingChargeRetry()`) and in `onDestroy()`.
- **Two distinct, accurate messages.** `buildChargingErrorMessage()` reads Android's own power state — `EXTRA_PLUGGED` from the sticky `ACTION_BATTERY_CHANGED` (`isExternalPowerConnected()`) and `BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` (`batteryCurrentNowUa()`, logged for diagnosis). If power is genuinely attached → *"unplug / remove from cradle, it will reconnect automatically"* (`reader_error_charging_plugged`). If nothing is plugged in yet the host still reports charging (a latched/phantom power state) → *"reboot the device to clear it"* (`reader_error_charging_stuck`). Both new strings translated into all 8 locales. The legacy `reader_error_charging` string is retained for `ReaderConnectionManager`'s parallel path.
- **Detection fixed (was dead code).** The historical charging check tested `statusDescription.contains("CHARGING")`, but the gate surfaces "Charging source connected" in the **vendor message** while the status is the generic `RFID_COMM_OPEN_ERROR` — so the branch never matched and the error fell through to the raw vendor string. Now matched case-insensitively against the vendor message.
- **Acknowledged modal instead of a fleeting snackbar** (`MainActivity`, `showChargingErrorDialog()` / `isChargingConnectionError()` / `dismissChargingErrorDialog()`). The charging failure carries multi-sentence guidance that's unreadable in a 3.5 s snackbar, so it now shows a `MaterialAlertDialogBuilder` modal the user must dismiss. It invites cycling the USB charging cable (**plug in, then unplug**) to clear the latched power state, has a **Retry** button that re-runs `connectToReader(true)`, and auto-dismisses when the power-clear auto-retry reconnects (`CONNECTED`). New strings `reader_charging_dialog_title` / `reader_charging_dialog_message` / `reader_charging_dialog_retry` in all 8 locales.

### Documented — underlying Zebra platform bug (`docs/ZEBRA-BUG-REPORT-tc701-integrated-rfid-charging-gate.md`)

- **Filed a detailed bug report for Zebra.** The integrated TC701 radio is hard-blocked by `com.zebra.rfidhost` (RFIDHostService v4.28) with `RFID_COMM_OPEN_ERROR` / vendor "Charging source connected" because the platform charge-detection (`vendor.zebra.hardware.batteryswap@2.0::IBatterySwapDevice`, surfaced via `com.zebra.batteryswapservice`) latches `AC powered: true` while `current_now` is negative (discharging) and **no cable/dock is attached**. The report traces the full call chain (app → RFIDAPI3 2.0.5.275 → `RfidHostManager` AIDL bind → `RFIDHostService.onCreate` → BatterySwap HAL → Qualcomm `TransportQC`/`qc_sdk` → Android `BatteryManager`), documents the contradictory power readings, and lists requested fixes. The QDat-side changes above are UX mitigation only — the root cause is in the Zebra firmware/services.

## [2.1.5] - 2026-06-09

TC701 integrated-radio support: the app now connects to a device's built-in Qualcomm RFID reader, identifies it properly, and lets you pin a specific RF mode that persists across sessions. Plus toolbar/stats/inline-button layout fixes.

### Added — selectable & persisted RF mode (`fragments/RFModesFragment`, `MainActivity`, `res/layout/fragment_reader_rf_modes.xml`)

- **Pin an RF mode that survives reconnects.** The **Reader Info → RF Modes** tab is now interactive: tap any mode card to select it — the index is saved to `RFIDAntennaSettings/rf_mode_index` and applied to every antenna on the live reader immediately. On every subsequent connect, `MainActivity` reads that key and reapplies the saved mode instead of recomputing the best match. A **Use Auto (Best Match)** button clears the selection (sentinel `-1` → previous auto-scoring behavior). Cards show a green **✓ Selected** badge that stacks with the existing **Active** / **★ Recommended** badges, and a status line reports Auto vs the saved mode. New `fragment_rf_modes_*` strings translated into all 8 locales.
- Verified on a TC701 integrated reader: selecting **Mode 4** and reconnecting logs `RF Mode set to saved user selection index 4` and applies it to antenna 1; Cooltag ARM confirmed working on that mode.

### Fixed — TC701 / integrated Qualcomm radio (`AndroidManifest.xml`, `services/RfidService`, `utils/ReaderCapabilityUtils`, `adapters/ReaderSelectionAdapter`, `MainActivity`)

- **App crashed on startup / "no reader" on devices with a built-in RFID radio.** Integrated readers are reached over the Zebra SDK's `QC_SERIAL` transport, which loads `com.qti.qrs.*` classes from the device's `com.qti.qrs.qc_sdk` shared library (`/system_ext/framework/qc_sdk.jar`). The app never declared that library, so `Readers.GetAvailableRFIDReaderList()` threw `NoClassDefFoundError: com.qti.qrs.QRSMemoryBank`. Declared `<uses-library android:name="com.qti.qrs.qc_sdk" android:required="false" />` in the manifest so the classes resolve on integrated devices while the app still installs everywhere.
- **Crash-safety.** Widened the reader-init catches in `RfidService` from `Exception` to `Throwable` (the failure is a `java.lang.Error`), and `enumerateReaders()` now falls back to Bluetooth-only enumeration if the QC library is genuinely absent (non-integrated devices) instead of crashing the service thread.
- **Reader identification.** The SDK reports the integrated radio with the generic model `"QC"`. `ReaderCapabilityUtils.resolveFriendlyModel()` / `parseHostModel()` derive the real identity from the `RFID-<hostModel>` device name + `Build.MODEL` → **"TC701 Built-in RFID"** with bus **"QC Serial/USB"** (was "Unknown RFID Reader / Unknown"). Applied in the metadata cache, the connected-reader display, and the selection list (model name, connection type, icon).

### Fixed — UI (`res/layout/toolbar_title_custom.xml`, `res/layout/activity_simple_rfid.xml`)

- **Toolbar version hidden on long flavor names.** "QDatDroid Enterprise" wrapped to two lines and pushed the version line out of the fixed-height toolbar. Line 1 is now single-line + ellipsized so the version stays visible.
- **Inventory stats stacked over 3 lines.** The top-right counters now read Tags / reads-per-second / Total on their own lines instead of cramming rate + total onto one.
- **Single-tag action bar icons moved left of text.** Each inline button (Locate / Read Logger / Read / ARM / Modify / Open URL / asset buttons) switched from icon-above-text to icon-left, icons 24dp→20dp, rows 56dp→44dp — freeing up to 36dp of vertical space for the tag details list.

## [2.1.4] - 2026-06-07

Read Logger chart polish and a fix for the "Start does nothing after closing a logger read" trap.

### Fixed — Read Logger chart (`res/layout/dialog_read_user_memory.xml`, `utils/ChartHelper`)

- **Chart fills its card.** The chart card's inner `LinearLayout` was `height="wrap_content"`, so the `LineChart` (`height=0dp, weight=1`) collapsed to its `minHeight` (120 dp), leaving a large empty gap below the from:/to: row and squishing the graph. Changed the container to `match_parent` so the chart expands to the full card height after the stats panel and from:/to: row.
- **Stray red square removed.** `ChartHelper.setupTemperatureChart` enabled the legend, but the temperature line is built from per-segment datasets with empty labels (green = in-range, red = out-of-range). The legend therefore rendered only blank colour swatches — a red one for any out-of-range run — floating above the Min stat. Legend disabled (`getLegend().setEnabled(false)`); Min/Avg/Max already live in the dedicated stats panel.
- **from:/to: labels track zoom/pan again.** `ChartHelper.enableDoubleTapZoom()` installed an all-empty `OnChartGestureListener`, which **overwrote** the dialog's own gesture listener (`OpusReadDialogHelper`, ~line 1383) that repaints the visible-range from:/to: timestamps. Double-tap zoom is a chart property (`setDoubleTapToZoomEnabled(true)`) and never needed a listener — removed the clobbering block so the caller's listener survives and the labels update live on pinch/drag/fling/double-tap. Removed the now-unused `OnChartGestureListener` / `ChartGesture` / `MotionEvent` imports.

### Fixed — inventory state desync after Read Logger / OPUS Status (`MainActivity`, `OpusReadDialogHelper`)

- **"Inventory already running" lock-out.** Closing the Read Logger / OPUS Status dialog (via the header **✕** or **Stop** — both dismiss identically) could leave the main-screen **Start** button gated with *"inventory already running"* while the radio was actually stopped, so the user couldn't restart scanning. Root cause: by design these dialogs do **not** auto-restart inventory on dismiss (the user restarts manually), but the in-dialog wake-up and live-refresh sweeps run their own inventory and left `MainActivity.isInventoryRunning = true` with no reset. On dismiss the "not restarting" branch now calls `inventoryCallback.onInventoryStop()`, and `MainActivity` responds with `forceMarkInventoryStopped()` to re-sync the flag and the Start/Stop button to the real (stopped) radio state. `BaseOpusDialogHelper.InventoryCallback.onInventoryStop()` already existed as a default no-op; the Read helper's callback now implements it.

## [2.1.3] - 2026-06-05

ControlTek customer build. Build/packaging and branding only — no app-behavior changes.

### Added

- **ControlTek "ControlDroid" customer build** (`app/build.gradle` `customer` flavor dimension): a second customer flavor (`controltek`) with its own `applicationIdSuffix` (`.controltek.<tier>`) and brand name "ControlDroid", shipping `ControlDroid-<ver>-controltek-<tier>.apk` alongside the unchanged QDatDroid artifacts. About-dialog title and trial-request dialog read "ControlDroid" for that flavor.
- **`EDITIONS-AND-LICENSING.md`**: editions / licensing reference.

## [2.1.2] - 2026-06-02

Reader-wedge hardening and a much more careful antenna-power lifecycle, plus a hidden in-app debug-log overlay, gentler stale-tag cleanup, and several Read Logger / OPUS Status reliability fixes.

### Added

- **In-app debug-log overlay** (`MainActivity`, `OpusReadDialogHelper`, `utils/DebugLogFilter`): swipe-right on the main screen or the Read Logger / OPUS Status dialog reveals a live tail of the app's own logcat (filtered to the RFID/OPUS tags), with tap-to-mute category chips (MQTT muted by default), touch-to-pause auto-scroll, and a persistent ring buffer. Gated behind **App → General → Developer → "Debug log (swipe right)"** (`AppGeneralSettingsFragment.KEY_DEBUG_LOG_OVERLAY`), default OFF — the swipe is inert unless enabled.
- **`docs/RFID-READER-WEDGE-HARDENING.md`** and **`docs/OPUS-AUTO-POWER.md`**: reference docs for the command-channel wedge recovery and the OCRSSI auto-power algorithm.

### Fixed — reader command-channel wedge (`RFID_API_COMMAND_TIMEOUT`)

- **`stopAccess()` watchdog** (`RfidInventoryManager.stopInventory`, `stopAccessWithWatchdog`): the readEvent stop runs `stopAccess()` on a bounded worker thread; on a timeout/no-return it stops issuing follow-up commands (no compounding `purgeTags`) and signals `onReaderUnresponsive` instead of cascading.
- **Fail-fast** (`forceReleaseSdkLock`): breaks on the first `RFID_API_COMMAND_TIMEOUT` (`isCommandTimeout`) instead of grinding all four recovery calls; benign idle "nothing to stop" statuses (`isNothingToStop`) are no longer logged or counted as failures.
- **Auto-recover** (`MainActivity.attemptReaderAutoRecovery`): `onReaderUnresponsive` now force-disconnects + reconnects to self-heal, falling back to the restart prompt only if that fails.

### Fixed — antenna power lifecycle

- **Verified restore** (`MainActivity.restoreAntennaPowerAfterReadDialog`): reads the power back and only stops once the radio confirms the saved index; retries ~20× over ~6 s (was 6× / ~1.2 s, which gave up while the radio was still busy and left it at a stale low level).
- **Re-assert on next Start** (`reassertAntennaPowerFromPrefs`, called from `startInventory`): compares live radio power to the saved pref and re-applies + verifies if they drifted, so power self-corrects even if the immediate restore lost the race.
- **Single restorer**: the dialog helper's own restore is now a fallback only (runs when `onDismissCallback` isn't wired), so the helper and MainActivity restores no longer contend for the SDK lock (`LOCK_ACQUIRE_FAILURE` storm).
- **Manual power sticks** (`PowerSliderHelper.OnPowerChangedListener` gains a `userInitiated` flag): a manual Apply in Read Logger suspends the auto-power loop for the session and persists on close instead of being overridden/reverted.

### Fixed — Read Logger / OPUS Status

- **OPUS Status freeze**: a cancelled bulk read left `isOperationInProgress` stuck true on the reused helper, gating off the next dialog's refresh loop — reset on open and dismiss.
- **Prompt cancel**: `OpusLoggerManager.isCancelled` is now `volatile`, so closing mid-read aborts within ~one block instead of grinding all 127.
- **Read after OCRSSI converges**: the bulk read now waits for OCRSSI to leave "Retrying"/"Starting" (valid 1–30), max ~8 s, instead of a flat delay; wake-up window 2.5 s → 6 s.
- **Decoded statuses**: TID/USER detail-read null retries get a 200 ms breather; `readEvent` restart retries once on `OPERATION_IN_PROGRESS`; Zebra statuses decoded (`formatOperationFailure`) instead of `null`.
- **OPUS Status loading label**: "Reading tag status…" (was the misleading "Preparing Memory Read…").
- **Chart fits**: the temperature chart flexes and the Local-time row no longer scrolls off; compact no-data message.
- **Violation counts on one line**: "N below X°C    M above Y°C".

### Fixed — tag list / filter

- **Stale-tag cleanup gentler + cheaper** (`TagInfo.staleMissCount`, `TagsAdapter.removeTags`): a tag must be unseen on 2 consecutive passes before removal (rides through transient read gaps), removals are batched into one adapter update, and empty passes are silent.
- **Detailed-list defaults locked** to font 10 sp / EPC width 147 dp; `fitTableColumnsToWidth` honors the configured width and sums only visible columns (the auto-fit was forcing ~83 dp by counting hidden columns); a one-time migration overwrites stale persisted values.
- **Display filter remembers TID** (`MainActivity.inlineActionTarget`): reuses the last resolved tag (incl. TID) when the live one drops out, instead of a null-TID stub.

## [2.1.1] - 2026-06-01

Trial-mode demo cloud access self-provisions, reader discovery survives a not-yet-granted Bluetooth permission, the single-tag action bar is reliable in isolated view, and the OPUS OCRSSI auto-power loop is rebuilt for fast, stable convergence.

### Added

- **Auto-provision demo cloud access in trial mode** (`MainActivity.startTagPhotoCapture`, `AssetDialogHelper.ensureConfiguredThenRun`, `ApiAccessSettingsFragment`): photo upload and the asset dialogs require cloud API access, but in trial mode the demo handshake was left half-done (`MODE_DEMO` + email, no org) and the only place to finish it — the API Access settings tab — was hidden by `HIDE_CLOUD_TABS`. Now an unconfigured trial mints a passwordless demo session on the fly (`startDemoSession` auto-selects the Demo org) before opening the camera / asset dialog, the API Access tab is re-exposed independent of `HIDE_CLOUD_TABS` (MQTT / Predict API stay hidden), and the fragment auto-provisions the demo org on open when the license is an active trial.
- **Read Logger / OPUS Status preserve antenna power** (`MainActivity.captureAntennaPowerForReadDialog` / `restoreAntennaPowerAfterReadDialog`, `OpusReadDialogHelper.setOnDismissCallback`): the in-dialog auto-power-cap shares the `antenna_1_power` pref with the main screen, so a transient drop could be left behind. The action handlers now snapshot antenna 1's power before the dialog opens and restore it (pref + radio) on every dismiss via a new unconditional dialog-dismiss callback.

### Changed

- **Auto-Isolate Loudest Tag moved** from App General Settings to **RFID Settings → Antenna** (`AntennaSettingsFragment`), keeping the `auto_isolate_enabled` key in the `AppSettings` pref so `MainActivity`/`TagsAdapter` consumers are unchanged.
- **Inline single-tag action bar stays put in isolated view** (`MainActivity.refreshInlineActionBar` / `inlineActionTarget`): the RFID rows (Locate / Read Logger / OPUS Status / ARM / Modify Memory / EM4425) now remain visible for the whole isolation session — they show whenever a display-filter EPC is active, not only when a live tag is in the list — and every button resolves its target via `inlineActionTarget()`, falling back to the filter EPC (reusing the last-known `TagInfo` from `tagsMap`, else a stand-in) so it re-acquires the tag through its SELECT wake-up instead of silently no-op'ing when the tag ages out.
- **Inline action-bar button colors** (`activity_simple_rfid.xml`, `colors.xml`): Modify Memory → yellow (`inline_modify_yellow`), Locate → blue (`inline_locate_blue`), Asset Picture → light-black (`inline_asset_light_black`).
- **OPUS OCRSSI auto-power loop rebuilt** (`OpusReadDialogHelper`): dB-accurate single jump (convert the OCRSSI error to a target dBm via `findIndexForDbm` instead of treating OCRSSI units as power-table indices — ~1-2 s to settle vs ~15 s of crawl); a deadband (hold while OCRSSI is in `[15,25]`, correct only at the edges) to stop noise-driven hunting; median-of-3 smoothing over the per-tick samples; saturation steps down a fixed 6 dB from the *current* power (not a fixed 15 dBm floor that a tag on the antenna can still saturate); a dynamic read floor pinned to the last *successful*-read power so chasing a low OCRSSI can't drop below where the USER read works (and can't ratchet to max); and an RF-RSSI > 3 dB move re-trigger that flushes the smoothing window to re-converge.

### Fixed

- **RFID reader search no longer throws `SecurityException` on a fresh install** (`MainActivity.connectToReader`): the Zebra SDK's `Readers.GetAvailableRFIDReaderList()` → `BluetoothAdapter.getBondedDevices()` was hit by the bind-time auto-connect before the user granted `BLUETOOTH_CONNECT`. `connectToReader()` now checks the permission via `hasBluetoothConnectPermission()` before touching the SDK and requests it once (a `bluetoothPermissionRequested` one-shot latch avoids a re-entrant request loop between the grant callback and `connectToReader()`); the latch re-arms once the permission is held. The `searchForReaders` `onError` path now surfaces a concise, branded **"Zebra RFD Bluetooth Failure"** (`R.string.rfid_bluetooth_failure`, localized in all 8 locales) instead of the raw SDK stack text.
- **ARM and Reset no longer time out on a stale/isolated tag** (`MainActivity.onArmClick` / `onResetClick`): both now run the same `runWithSelectWakeUp` SELECT sweep as Read Logger / Read Memory before opening their dialogs, so the dialog's TID/USER reads start hot instead of grinding through NO_RESPONSE retries (fast "out of range" bail when the tag is truly gone).
- **EM4425 Read URL / DPP Write work off the display filter** in isolated view: the inline buttons fall back to the filter EPC + wake-up when the live tag has aged out, instead of silently doing nothing.

## [2.1.0] - 2026-06-01

Device-bound licensing &amp; entitlement suite — request/activate licenses and trials, a client-enforced OPUS-tag quota, QDat.io provisioning over QR and REST, and a redesigned License / MQTT-setup UX.

### Added — Licensing &amp; entitlement

- **License layer** (`license.*`): `request-trial` / `request-license` / `validate-license` / `revoke-license` / `sync-license` against `POST /api/v1/licenses/*` on the per-instance host (`api.<instance>.qdat.io`, default `tapdpp`, from `SecureApiStorage.getBaseUrl()`). The device is bound to `Settings.Secure.ANDROID_ID` (`DeviceId`), sent as both `device_id` and `mac_address`; `product` is the fixed `qdatdroid`.
- **`max_tags` tag quota** (`LicenseEnforcement` / `LicenseGate` / `SecureLicenseStorage`): each successful ARM commits the tag's EPC to an encrypted distinct-EPC ledger (ARM is one-way → one slot per tag). A decreasing **"Tags: N left of M"** counter; a soft prompt past half-consumed; and a **hard ARM block at 0** (`showTagQuotaBlockedDialog`). Inert when `max_tags ≤ 0`.
- **Combined provisioning QR**: one scan provisions MQTT **and** activates a self-contained inline `license` object (`MqttQrCodeParser` reads `license.key` + the full object + `api_base`). Offline activation via `LicenseRepository.acceptInlineLicense`, then a background `validate-license` reconcile.
- **Trial / license REST response provisions MQTT** directly via the same `mqtt` + `device` blocks as the QR (`TrialResponse.MqttSection`/`DeviceSection` → `applyMqttFromResponse`).
- **`license_refresh` MQTT command** (push-to-refresh) on `devices/{UUID}/command` → triggers `sync-license`; the **Refresh License** button does the same on demand (reports `arm_count`/`read_count` so the backend can grant a larger allowance).
- **Lodged license requests remembered** (`SecureLicenseStorage.saveLicenseRequest`) → the cloud chooser item becomes **"Check on License Request"** and the License tab shows a copy of the request + the next-step explainer (Submit hidden, Scan QR lit). Cleared on activation / delete.
- **Demo** settings tab; a demo HH:MM:SS countdown overlaid above the cloud; demo teardown (`DemoEntitlement.clear`) on "Delete License &amp; MQTT Credentials".

### Changed

- **License binding key**: WiFi MAC → **Android device ID** throughout (UI fields, request bodies).
- **License settings tab** redesigned: inline **Request License / Request Trial** forms (device ID + name + email + submit), **Scan QR** (the return step), **Refresh License**, and a red two-line **Delete License &amp; MQTT Credentials**. The global **Save and go to Main screen** stays unlit until a request/scan/refresh (`SaveGate`). Expiry shown **date-only** ("1 Jul 2026") plus the **License No.**.
- **Main-screen cloud**: **QDat.io Setup** branding (the `qdat-favicon` logo + "QDat.io Setup" label when unconfigured). The MQTT setup chooser is a styled white-button list: **1-DAY TRIAL on TapDPP.QDat.io / Request 30-Day Trial / Request License / Manual** (enterprise) **/ Cancel**, each flashing on press; the two "Request" items deep-link to the License tab and open the matching form.
- **Cloud settings tabs hidden** for now (MQTT / Predict API / API Access) — MQTT provisioning moved to the License tab's Scan QR.
- Product code `cooldroid` → **`qdatdroid`**; license endpoints moved under **`/api/v1/licenses/`** to match the rest of the QDat.io API.

### Fixed

- **Request License auto-grant** no longer re-shows the empty form — the issued license is now activated and surfaced.
- **Detailed-list table header** no longer wraps **RSSI / Cnt** onto two lines; the header now tracks the data-column widths and font scale.
- **MQTT auto-reconnect** NPE crash (`setAutomaticReconnect(false)` + the custom watchdog).
- NFC URL spans the full width in table view; NFC mode is preserved across the card/table toggle; table columns auto-fit the screen width.

### Docs

- `docs/QDATIO-LICENSE-MANAGER.md` (the QDat.io backend contract) + `docs/qdatio-licenses.openapi.yaml` (OpenAPI 3.1 for the license + demo routes) + `docs/QDATIO-QR-SIGNING-FUTURE.md` (current unsigned/access-gated trust model and the future Ed25519 signing plan).

## [2.0.18] - 2026-05-31

QDat cloud asset management — sign in to a QDat instance, attach photos to tags, and view/edit asset records — plus auto-isolate of the loudest tag.

### Added — QDat cloud asset management

- **Cloud API layer** (`api/cloud/`): Retrofit `QdatCloudApi` + `QdatCloudClient` + DTOs + encrypted `SecureApiStorage`, talking to `api.<instance>.qdat.io` (`/api/v1`). Gated behind the cloud tier via `Features.cloudApi()`.
- **API Access settings tab** (`ApiAccessSettingsFragment`): two sign-in paths — passwordless **TapDPP Demo** (`POST /demo/visitor-session`, bound to the backend Demo org) and credentialed OAuth2 login — with a configurable instance, a live base-URL preview, an organisation picker (the department where the reader was registered), and credentials persisted encrypted. MQTT demo provisioning pre-fills demo mode. Token re-mints/refreshes on expiry. The org-list call uses the trailing-slash form to avoid the proxy's `http://` redirect (the earlier "network error").
- **Per-tag asset actions** (card-view button row + inline single-tag bar, shown when a tag is isolated). Card labels: **Picture / Show / Edit**.
  - **Picture** — in-app CameraX capture (`CameraCaptureActivity`), downscaled JPEG, multipart upload (`POST .../tags/{id}/upload-image`) with multi-org EPC resolution.
  - **Show** — tabbed dialog (`dialog_asset_show.xml`): swipeable picture **carousel** (hand-rolled `HttpURLConnection` + `LruCache` loader, no new deps), **Info**, and **Times** (`GET .../tags/{id}/events`) with a local/UTC switch.
  - **Edit** — editable form (date picker for `acquisition_date`, numeric keyboard for `asset_value`, blank→null so the backend doesn't 422), per-image delete (`DELETE .../images/{idx}`); Cancel only dismisses; failures toast the server detail.
  - EPC resolves from the tag or the display-filter field, so actions run with no refreshed tag; the refresh sweep is best-effort and never blocks the action.
  - RFD sled trigger fires the camera shutter (via `TriggerController.setPressOverride`) and is suppressed from toggling inventory while any asset screen/dialog is open.

### Added — Auto-isolate loudest tag

- Opt-in (`AppSettings/auto_isolate_enabled`, default off). While scanning, the loudest fresh tag (RSSI closest to 0) that leads the runner-up by ≥4 dB for 3 continuous seconds is written into the display filter; stays until the user clears the filter and never overrides a manual filter. New checkbox in General settings; evaluator runs on the stale-cleanup cadence.

### Changed

- First-run lead-gate wording: "enterprise demo" instead of "30-day trial".
- All new UI strings localised across the 8 locales.

### Build

- **Version bump**: `2.0.17` → `2.0.18` (versionCode 73 → 74).

## [2.0.17] - 2026-05-30

A follow-up polish pass on the Tag Locate chart based on live UX feedback after 2.0.16 shipped.

### Added

- **"Log" checkbox in the Locate Tag dialog footer** (left of "Smooth"). When ticked, the right-axis y-values are passed through a normalized log10 transform (`log10(p + 1) / log10(101) × 100`) so low signal levels (0–10 %) take up the bottom half of the chart instead of being squashed into a few pixels. Tick labels still read `0…100` thanks to the inverse-transform `ValueFormatter`. Default off, persisted as `AppSettings.locate_log_axis`. Helpers added to `TagLocateHelper.java`: `axisTransform(float)`, `inverseAxisTransform(float)`, `addAllZoneLimitLines(YAxis)`, `applyRightAxisRangeForMode(YAxis)`, `rebuildRightAxisForMode()`, `buildDisplayEntries()`, `makeSessionPeakLine(float)`.

### Changed

- **Right axis is now permanently locked to [0, 100] in both modes.** The previous adaptive auto-scale (`adaptRightAxisToSignal`) made the right-side tick marks slide on every refresh, which was visually noisy — the labels jumped around as min/max recomputed. The Log toggle now controls only the y-transform of the curve / zone bands / peak line; the axis labels themselves stay anchored. `adaptRightAxisToSignal` is left in the file as dead code in case a future toggle wants it back.
- **`percentageHistory` now stores raw 0–100 percentages** instead of the previously-plotted (potentially transformed) value. `buildDisplayEntries()` walks the raw buffer and applies the current `axisTransform` on every render, so flipping the Log toggle mid-session repositions the curve immediately without losing the in-flight sample history.
- **Adaptive-mode auto-scale floor span bumped 10 → 30** (`adaptRightAxisToSignal`) and the single-sample-cold-start fallback widened ±5 → ±15. Mitigation for the "zooms way too much" complaint in case `adaptRightAxisToSignal` is ever re-enabled.
- **Value chip moved from chart top-right to top-center** (`dialog_locate_tag.xml:139-150`): `layout_gravity` `end|top` → `center_horizontal|top`, `layout_marginEnd` removed. The chip now sits centered horizontally over the chart at the same `12dp` top margin so it doesn't compete with the right-axis tick labels for screen space.

### Build

- **Version bump**: `2.0.16` → `2.0.17` (versionCode 72 → 73).

### Documentation

- **`docs/ZEBRA-SDK-2.0.5.275-DEVICES-EN.md`** added (with the source PDF `docs/ReleaseNotes_ZebraRFIDSDKForAndroid_2.0.5.275.pdf`). Summarizes the Zebra RFID SDK 2.0.5.275 release that QDatDroid bundles as `app/libs/rfidapi3lib-2.0.5.275.aar`: the cumulative SDK changes (RFD4051 + ET401/TC501/TC701 device support, Gen2x inventory performance, integrated library, updated reader logging) and the full list of compatible Zebra readers with their Android-version floors. Reference doc for answering "what Zebra hardware does QDatDroid support".
- **"Looking ahead: RFID is moving into the silicon"** section added to the same doc, covering the **Qualcomm Dragonwing Q-6690** — launched Sep 2025, CES 2026 Innovation Award honoree, billed as the world's first enterprise mobile processor with a fully integrated UHF (RAIN) RFID reader. Key point for us: **Zebra is a named launch OEM** (alongside Honeywell, Urovo, HMD Secure, CipherLab), making a sled-free single-device QDatDroid handheld a strong likelihood if Zebra surfaces the on-die radio through the same RFID API3 SDK. Includes a selected-specs table, industry-validation notes (OEMs, Decathlon / EssilorLuxottica, RAIN Alliance, ~30 % device-cost reduction), and a to-be-confirmed caveat. Datasheet archived as `docs/Qualcomm_Dragonwing_Q-6690_Processor_Product_Brief.pdf`.
- **"What '6 TOPS' means — and how QDatDroid would use it"** subsection added. Explains the Hexagon NPU and the TOPS (tera-operations-per-second) metric in plain terms, why a dedicated accelerator matters for a battery handheld, and the on-device-AI roadmap for QDatDroid: moving the cloud **Predict API** food-quality inference onto the device (offline / instant / private), real-time temperature-excursion anomaly detection, Tag Locate assist, label/expiry OCR, and local natural-language batch summaries.
- **`docs/QDATDROID-FEATURES-OVERVIEW.md`** added — the canonical customer-facing feature catalog for QDatDroid, intended as the single source to point at when writing/editing QDat.io website copy. Consolidates the per-edition feature matrix, plain-language descriptions of every feature area (reading/inventory, temperature history, Tag Locate, GPS/maps, NFC/EM4425, ARM, export, MQTT/Predict API, reader config, app experience), supported hardware, a clearly-marked roadmap (sled-free scanning, on-device AI), a quick feature-to-edition matrix, and a source-of-truth map pointing back to the underlying docs.

## [2.0.16] - 2026-05-30

A polish pass on the OPUS auto-power loop (saturation handling), the OPUS Status / Read Logger battery display, the OPUS ARM dialog layout, the Tag Locate chart, and a longstanding "reader icon is red even though the radio is up" UI bug.

### Added

- **OCRSSI saturation-recovery mode in the auto-power-cap.** Both `OpusReadDialogHelper.java` and `OpusArmDialogHelper.java` now detect `OCRSSI == 31` (detector pinned) and switch the live antenna power to a 15 dBm floor (via the new `PowerSliderHelper.findIndexForDbm(double)` table lookup) instead of jumping to index 0. The dialog stays in a sticky recovery mode for the rest of the session: `target = 16`, `cap = 18`, and the proportional climb step is multiplied by 4 (`OCRSSI_RECOVERY_CLIMB_BOOST`) when ocRssi is below target so the climb finishes in 2–3 ticks instead of crawling 0.x dB at a time. Reset on dialog open alongside `savedPowerIndexForRestore`.
- **Rolling battery-voltage average in `BaseOpusDialogHelper`.** New 10-sample EMA-free rolling buffer in `addBatteryReading(double)` / `clearBatteryBuffer()` smooths the displayed voltage so it doesn't change on every refresh tick. Zero/NaN readings are filtered (silent ticks don't poison the average). Wired into the refresh paths of both Read Logger (`OpusReadDialogHelper.java:3911-3919`) and OPUS Arm (`OpusArmDialogHelper.java:585-589`); buffer is cleared in `cleanupOnDismiss` so each session starts fresh.
- **"OPUS Arm — N/A Logging" affordance.** When a tag's PC-word state decodes to `LOGGING` the inline row ARM button (`item_tag.xml:btn_arm`) is now shown disabled with text "N/A Logging" at 40 % alpha, and the long-press popup menu adds a disabled "OPUS Arm — N/A Logging" entry. SLEEP / STANDBY / BAP_MODE still show the normal enabled "OPUS Arm". New string `R.string.arm_na_logging`. Logic at `TagsAdapter.java:1075-1100` (visibility + flag) and `:2440-2450` (popup entry).
- **Locate Tag chart smoothing toggle.** New "Smooth" checkbox in the dialog footer (left of "Beep") that controls an EMA filter (`α = 0.25`) applied to the right-axis quality % curve. Saved to `AppSettings.locate_smooth_enabled`. Smoothing accumulator resets on session start / on toggle so a fresh locate never blends with a stale value. RSSI raw / smoothed readings in the metrics card stay raw.
- **Locate Tag chart hero redesign.** Single CUBIC_BEZIER curve (no more dual axes — the dBm line is gone), vertical proximity-color gradient fill via `drawable/locate_proximity_gradient.xml` (blue → amber → green), four dashed zone `LimitLine`s at 33 / 50 / 67 / 83 % labeled Far / Medium / Close / Very Close, session-peak `LimitLine` in success-green ("Peak NN%") that slides up but never down, floating value chip (`drawable/locate_value_chip_bg.xml`, `R.id.tv_locate_live_value`) in the top-right showing the live `NN%` with stroke color tracking the proximity bucket, time-based X axis (`Ns` labels via `ValueFormatter`), 800 ms `EaseOutCubic` entrance animation, chart `minHeight` 280 → 340 dp. Adaptive right-axis range was already added in the previous build via `adaptRightAxisToSignal`.

### Changed

- **Climb power on refresh-loop read errors in OPUS Arm too.** The refresh-loop `catch` block at `OpusArmDialogHelper.java:650` now climbs the antenna by 4 power-index steps (capped at `savedPowerIndexForRestore`, gated by the 250 ms throttle) on any thrown exception during the periodic read. Mirrors the existing block in `OpusReadDialogHelper.java:4118`. Gives the dialog a chance to recover next tick instead of staying silent at a low power level.
- **LED on-time / off-time spinners always visible in the OPUS Arm dialog.** `dialog_arm_logger.xml` `layout_led_options_dialog` no longer ships `visibility="gone"`. The "Enable LED" checkbox now toggles `setEnabled` + `alpha = 0.4f` on the spinners instead of collapsing the row. Stale `setVisibility(View.GONE)` calls in the profile-load path at `OpusArmDialogHelper.java:1524` and `:2911` were also removed (they were re-hiding the row whenever a profile was loaded).

### Fixed

- **Reader icon stayed red when the RFID radio was actually connected.** `MainActivityViewModel` starts at `DISCONNECTED` (`viewmodels/MainActivityViewModel.java:43`); it was only promoted to `CONNECTED` inside the broadcast handler at `MainActivity.java:9023`. When the activity recreated (theme change, rotation, return-from-settings) and rebound to an already-connected `RfidService`, no fresh `CONNECTED` broadcast fired — the ViewModel stayed at `DISCONNECTED` and the observer at `MainActivity.java:7153` re-tinted the icon red even though the service-bind path had set it green. The `onServiceConnected` `runOnUiThread` block at `MainActivity.java:434` now calls `viewModel.setConnected(rfidService.getReaderDevice().getName())` for symmetry; the `else` branch at line 464 calls `viewModel.setDisconnected()`. Observer is now in lockstep with the actual radio state across activity recreations.

### Build

- **Version bump**: `2.0.15` → `2.0.16` (versionCode 71 → 72).

### Documentation

- **`docs/NOTE-LOCATE-SIGNAL-QUALITY-EN.md` / `-FR.md`** added with PDF renderings. Reference doc explaining the two independent "quality" numbers in the Locate Tag dialog — the linear `rssiToPercentage` distance proxy that drives the chart curve / value chip / peak line, and the standard-deviation stability metric in `LocateMetrics.getSignalQuality()` that drives the bar. Includes formulas, worked examples, EMA smoothing behavior, and a 2 × 2 interpretation matrix for operator training. Same pandoc → `release-notes-style.css` → Chrome-headless rendering pipeline as the existing release-notes documents.

## [2.0.15] - 2026-05-29

A small UX polish pass on the inline action bar and the long-press popup menu.

### Added

- **Adapter-pause guard for the long-press popup menu.** While a popup is open, `TagsAdapter` defers all `notifyItem*` and `notifyDataSetChanged` calls so the row underneath the popup doesn't move as inventory keeps firing. On `OnDismissListener` the flag flips back and a single coalesced `notifyDataSetChanged` flushes the accumulated state. New private fields `updatesPaused` and `hasPendingUpdate`; new public method `setUpdatesPaused(boolean)`. Both popup-construction sites (the NFC-only path at the inline row click and the main UHF/OPUS table popup) call `setUpdatesPaused(true)` before `popup.show()` and wire the dismiss listener. Drawback documented: the row data freezes for the menu's lifetime (RSSI / count / sort positions stale until dismiss) — radio keeps reading, but display is frozen.

### Changed

- **EM4425 DPP Write button background → `@color/tag_type_opus` (purple `#9C27B0`)**, away from the previous `@color/warning_color` orange. Sits in row 2 of the inline action bar next to the EM4425 Read URL button; the OPUS-purple already used elsewhere keeps the palette consistent and visually distinguishes the EM-specific actions from the generic warning-toned ones.
- **Modify Memory now stops inventory and runs the SELECT wake-up sweep before the write dialog opens** (`MainActivity.onModifyMemoryClick`, both anonymous TagActionListener implementations). The flow now matches Read Logger / Read Memory / Locate / EM4425 actions: `forceMarkInventoryStopped()` → `stopInventoryForDialogAsync` → `runWithSelectWakeUp(tagInfo, "Modify Memory", ...)` → `tagMemoryWriteHelper.showWriteDialog(...)`. Previously the flow only called `stopInventoryForDialogAsync` (no immediate UI flip, no SELECT sweep), so the write dialog could `NO_RESPONSE_FROM_TAG` on first attempt if the tag's session flag had lapsed.
- **Modify Memory dialog no longer auto-restarts inventory on dismiss.** Call site now passes `wasInventoryRunning = false` to `tagMemoryWriteHelper.showWriteDialog`, matching Read Logger / Locate / OPUS Status. The dismissed dialog leaves inventory stopped; the user manually restarts via the Start button. Previously the dialog auto-restarted whenever inventory was running before open, which was inconsistent with the other tag-action dialogs.

### Build

- **Version bump**: `2.0.14` → `2.0.15` (versionCode 70 → 71).

## [2.0.14] - 2026-05-29

A targeted cleanup of the OPUS dialog flows. Most fixes converge on a single root cause: helper-level state (the periodic-refresh in-flight flag, the inventory-manager "instant temp" toggle) was carrying across sessions when it shouldn't.

### Added

- **Popup-menu entry "Logger Status"** on any OPUS tag (any state). Mirrors the inline OPUS Status button — opens the OPUS dialog in `statusOnly` mode, no log pull, no auto-route to ARM. Routed to the existing `onOpusStatusClick` callback. Sits above "Read Logger" in the popup so the read-only view is the first option a user reaches.

### Changed

- **OPUS Status tab set is now Status + Configuration** (no Logger Data). 2.0.12 shipped Status + Logger Data with Configuration hidden; user feedback was that the two screens (Read Logger vs OPUS Status) looked identical because both showed the chart. The Logger Data tab is now Read-Logger-only — OPUS Status is the read-only "what is this tag currently doing" view. Tab-index switch in `OpusReadDialogHelper.java:1051-1075` updated so position 1 maps to Configuration in `statusOnly`, Logger Data otherwise.
- **OPUS Status does not auto-pull the temperature log on open** (`OpusReadDialogHelper.java:2015` — auto-trigger gate now `finalConfiguredSamples > 0 && !statusOnly`). The Status tab + periodic refresh of the instantaneous-temperature register is sufficient for the read-only flow; the log pull belongs exclusively to Read Logger.
- **"Exit to Main Screen" footer button removed from the Configuration tab** in both Read Logger and OPUS Status flows. The dialog already ships with a bottom "Stop" button and the close-X in the toolbar; the per-tab Exit button was redundant.
- **Periodic-refresh cadence dropped to 1 s** (`BaseOpusDialogHelper.REFRESH_INTERVAL_MS` 4000 → 1000). The instantaneous temperature on the Status card now tracks live USER-word-2 reads at 1 Hz instead of every 4 s. The existing `isPeriodicRefreshInFlight` guard at the scheduler drops a tick whenever the previous readWait hasn't completed, so a slow radio degrades back to its actual response cadence — earlier 1.5 s attempts saturated because that guard wasn't in place yet.
- **Temperature averaging window reduced to 1** (`BaseOpusDialogHelper.TEMP_AVG_WINDOW` 5 → 1). At 1 Hz the previous 5-sample rolling buffer added a 5-second display lag and users could not see fresh measurements landing on full / FINISHED tags. The buffer/NaN-handling machinery stays in place but no longer averages.
- **`readOpusInstantTemp` defaults to `false` in both `RfidInventoryManager` and `RfidService`** (was `true` in-memory in both classes, despite the prefs-read default being `false` and the checkbox unchecked). The mismatch silently ran the feature in the window between service construction and the MainActivity prefs push, which meant a freshly-started inventory would stop-and-USER-read after the first OPUS tag was seen — every ~1.5 s — and the user couldn't see what was happening.

### Fixed

- **OPUS Status periodic refresh wasn't firing at all** when the user had previously opened (and dismissed) the Read Logger dialog. Root cause: `refreshTagData`'s dismissed-early-return path at line 3658 didn't clear `isPeriodicRefreshInFlight`, so the flag stayed `true` across the dialog dismiss. The next dialog's runnable saw `isPeriodicRefreshInFlight = true` and skipped every tick. Two-pronged fix:
  - `showOpusLoggerDialog` now resets `isPeriodicRefreshInFlight = false` alongside `isDialogDismissed = false` at dialog open.
  - The dismissed-early-return in `refreshTagData` clears the flag before returning so the leak doesn't recur.
- **Read Logger silently refused to download anything on a full tag** — a regression from the 2.0.13 "256 samples on empty tag" fix. When `nextLogAddress == configuredSamples` (tag is full), the partial-fill branch's strict `<` comparison failed and no other branch matched, so the sample count stayed at 0 and the read bailed via the existing `samples <= 0` early-exit. New explicit branch handles `nextLogAddress >= configuredSamples` and reads all `configuredSamples` slots; comment also notes some firmwares advance `nextLogAddress` past the cap, which the `>=` guard tolerates.

### Build

- **Version bump**: `2.0.13` → `2.0.14` (versionCode 69 → 70).

### Documentation

- **`docs/RELEASE-NOTES-2.0.11-TO-2.0.13.md` / `-FR.md`** added with PDF renderings. Plain-language tour of the 2.0.11 → 2.0.13 work for review (English + French). Markdown source + Chrome-headless-rendered PDF (Skia/PDF producer, matching the existing `docs/*-FR.pdf` convention). These were produced earlier in the session before the 2.0.14 work landed; the 2.0.14 CHANGELOG entries above are the source of truth for the new range.

## [2.0.13] - 2026-05-29

### Fixed

- **TagLocate TOO CLOSE false positive.** The 2.0.12 "TAG NEARBY — TOO CLOSE TO READ" hold was tripping at RSSI ranges of -50 to -70 dBm (mid-distance, not close at all). Root cause: the peak-tracker only updated the recorded proximity *upward*. Once a read came in at high signal (say 100 % at -40 dBm), subsequent moves away from the tag (lower proximity) never lowered the recorded peak — the gate kept thinking we were still at peak strength even after the user backed off. When reads then went silent at moderate distance, the hold fired against the stale 100 % value.
- The peak-tracker now records the LATEST proximity on every read instead of the maximum-since-start. When reads go silent, the gate looks at how strong the most recent read was — i.e. were we actually hot when reads stopped? — and the hold only fires when the answer is yes. The `relativeDistance` percentage already in the read loop (linear `rssiToPercentage`: -90 dBm → 0 %, -30 dBm → 100 %) is the correct input; the previous version was passing `locateMetrics.getSignalQuality()` which measures RSSI variance/consistency, not proximity.

### Build

- **Version bump**: `2.0.12` → `2.0.13` (versionCode 68 → 69).

## [2.0.12] - 2026-05-29

A late-night cleanup pass aggregating everything that landed on `main`
between the 2.0.11 release and the version bump tonight. Eight PRs
(#8–#13) merged across the day plus four in-branch fixes captured here.

### Added

- **TagLocate — TOO CLOSE / saturation-silence handling.** Zebra radios stop emitting tag reads when the antenna saturates at close range, which the no-signal handler interpreted as "tag is gone" and collapsed the proximity bar just as the user found the tag. New saturation-detection: when the recent peak signal was ≥ 80 % within the last 5 s and reads then go silent, the helper enters a 5 s "hold" — the displayed values freeze instead of snapping to 0 and an orange `TAG NEARBY — TOO CLOSE TO READ` banner (`tv_locate_too_close_badge`) appears below the dialog header. A real read clears the hold immediately; if the hold window expires without reads, the regular reset runs. Tracks `recentPeakSignalQuality`, `recentPeakTime`, `inTooCloseHold`, `tooCloseHoldEnteredAt`. Hold duration is keyed off when hold was entered, not off the peak timestamp, so a stale-but-recent peak still grants the full 5 s of hold.
- **TagLocate — auto-restart locate inventory after post-hold silence.** EPC Gen2 session persistence keeps a recently-acknowledged tag in the "B" state for tens of seconds; the locate's own inventory keeps querying state-"A" tags and gets nothing, so the user previously saw 15–20 s of zero readings after backing away from the tag. New `postHoldSilenceStartedAt` timer is armed when a hold falls through to full reset; after `POST_HOLD_RESTART_AFTER_MS` (3 s) of further silence the helper calls `reader.Actions.Inventory.stop()` + `perform()` to cycle the session. The tag becomes visible again within ~hundred-ms instead of waiting for the session flag to decay.
- **SELECT wake-up sweep before Tag Locate.** `MainActivity.onLocateClick` (both anonymous TagActionListener implementations) now wraps `showLocateDialog` with `runWithSelectWakeUp`, matching the Read Logger / Read Memory / EM4425 URL / EM4425 DPP Write flows. Resolves the "press Locate multiple times, nothing happens until I press Start/Stop" symptom on isolated single-tag rows — the wake-up re-singulates the tag so the locate inventory starts hot.
- **Snackbar string `opus_read_logger_no_samples_yet`** — "No samples logged yet — try again after the next logging interval". Surfaced by the Read Logger flow when the tag is configured but `nextLogAddress == 0`.
- **Layout slot for TagLocate TOO CLOSE badge** in `dialog_locate_tag.xml`. Declared with `visibility="invisible"` (not `gone`) so toggling it on doesn't shove every widget below it; the chart and radar stay put across hold transitions.

### Changed

- **Read Logger — `nextLogAddress == 0` is treated as exactly 0 samples**, not as a cue to speculatively read 256 (`OpusReadDialogHelper.java:1687–1710`). The previous code returned 256 zero-filled words from un-written USER memory and the parser counted them as real samples — "Reading complete: 256 samples" on a freshly armed tag. The early-exit branch at `samples <= 0` now picks between `opus_read_logger_not_configured` (`configuredSamples == 0`) and the new `opus_read_logger_no_samples_yet` (`configuredSamples > 0 && nextLogAddress == 0`). The auto-refresh that watches `nextLogAddress` advancing still picks up the first real sample as soon as it lands.
- **LED On Time / LED Off Time labels wrap to two lines** in `item_arm_profile_accordion.xml`. `led_on_time_label` becomes `LED On Time\n(milliseconds)` and `led_off_time_label` becomes `LED Off Time\n(seconds)` so both spinner rows have the same label height and the spinners themselves align vertically.

### Build

- **Version bump**: `2.0.11` → `2.0.12` (versionCode 67 → 68).

### Documentation

- **CHANGELOG entry consolidates eight already-merged PRs** that bumped main but didn't get individual version numbers:
  - PR #8 `fix: dispose Zebra SDK Readers on disconnect` — adds `readers.Dispose(); readers = null;` to `RfidService.disconnectReader()` so the next connect starts from a fresh SDK state. Previously the SDK only reset on process kill, so radio-stuck conditions (`RFID_OPERATION_IN_PROGRESS`, persistent `Radio Response Timeout`) survived in-app disconnect/reconnect cycles. The existing triple-stop recovery gesture (`MainActivity.java:7475`) inherits the SDK reset for free.
  - PR #9 `feat: persist table column widths + font size across app restarts` — `TagsAdapter` constructor now loads `table_font_sp`, `table_epc_width_dp`, and `table_col_width_<id>` (rssi / count / status / temp / battery) from the `AppSettings` prefs and clamps through the same MIN/MAX bounds the setters use. The setters (`setEpcColumnWidthDp`, `setColumnWidthDp`, `setTableBaseFontSp`) each persist with a one-line `edit().put*().apply()`. Stale "persisted in prefs" comment on the EPC field corrected.
  - PR #10 `fix: stop card-view tag rows from swapping on every inventory tick` — adds `firstSeenTime` to `TagInfo` (initialized via field initializer so every instance gets a valid timestamp without special-case handling in the inventory path). Card-view branch of `applyHybridEpcSort` now keys on `firstSeenTime` instead of `lastInventoryTime`. Two tags in the field no longer swap row positions every refresh; the user-visible "disco" was a stable-discovery-order layout described in the comment but implemented as a re-detection-order layout. Table view keeps `lastInventoryTime` since RSSI is the primary key and the time tie-break is rarely visible.
  - PR #11 `ui: rename inline Read button → "OPUS Status" + fit row-2 labels` — new `inline_action_opus_status = "OPUS Status"` string used only by the inline button (popup-menu Read Memory keeps `read_memory`). Row-2 buttons (Modify Memory · Open URL · EM4425 Read URL · EM4425 DPP Write) drop to 10sp + `maxLines=1` so the rename of EM4425 URL → EM4425 Read URL and EM4425 DPP Write both fit on one line.
  - PR #12 `feat: OPUS Status as clean read-only view + dedicated inline ARM button` — adds `onOpusStatusClick` to `TagActionListener`, wires the inline OPUS Status button to it. New `readCompleteTagDataAndShowDialog(TagInfo, boolean, boolean)` overload + `OpusReadDialogHelper.showOpusLoggerDialog(TagInfo, boolean, boolean)` overload accept a `statusOnly` flag: when true, the SLEEP/STANDBY/BAP_MODE → ARM auto-route is suppressed and the Configuration tab is not added to the TabLayout. Inline action bar Row 1 grows from 3 to 4 equal-weight buttons (Locate · Read Logger · OPUS Status · ARM) with all Row 1 labels dropped to 10sp + `maxLines=1`. New `btn_inline_arm` reuses `@string/arm` + `@drawable/ic_status_armed` + `warning_color` background. ARM dialog hides `btn_arm_logger` when `tagInfo.opusInfo.getState() == LOGGING` — only Cancel is clickable for actively-logging tags.
  - PR #13 (this version's flagship) — see "Changed" above for the Read Logger 256-samples fix.
- **Out-of-band direct-to-main commits**:
  - `b76e595` `ui: rename EM4425 URL action to "EM4425 Read URL"` — `action_em4425_url` value updated from "EM4425 URL" to "EM4425 Read URL"; pairs the verb with the existing "EM4425 DPP Write" so both popup-menu / action-bar entries read consistently.
  - `a7b0cfe` `docs: move EM4425 personalization PDF into docs/em4425/ subfolder` — 609038 datasheet PDF relocated alongside the EM4425 README + Em4425NdefIO reference.
  - `00fea2e` `docs: French translation of MQTT command-channel design doc` — adds `docs/DESIGN-MQTT-COMMAND-CHANNEL-FR.md` + `.pdf` rendering of the in-progress 2.0.10 design document.

## [2.0.11] - 2026-05-28

### Added
- **EM4425 URL action — blind UHF read of the NFC NDEF region.** New popup-menu entry "EM4425 URL" and inline-action-bar button on any non-NFC tag row. Reads USER bank starting at word 0xA0 (the EM4425's NFC Forum NDEF region per the 609038 personalization datasheet), parses the TLV envelope and URI record, caches the URL on `tagInfo.nfcUrl`, registers it with `MqttManager.setNdefUrl(epc, url)`, and fires one immediate `publishScanData` so the broker sees it without waiting for the next inventory tick. Bounds-tolerant parser: clamps to whatever bytes the wire actually returned, so short reads still yield a usable URL.
- **EM4425 DPP Write action — hard-coded URL writer.** New popup-menu entry "EM4425 DPP Write" and inline-action-bar button (warning-orange, 4th slot of row 2). Builds `https://tapdpp.qdat.io/<EPC>`, encodes it as an NFC Forum NDEF URI record wrapped in CC + Message-TLV + terminator, **pre-clears the target region** (writes 26 words of `0x0000` to USER@0xA0 — necessary because writing over existing data triggers `NO_RESPONSE_FROM_TAG` on the EM4425/Zebra combo), then writes the real NDEF. On success caches the URL + republishes via MQTT and surfaces it under the TID in the row.
- **UHF Memory Bank Browser dialog.** Read Memory on a non-OPUS UHF tag now opens a full-screen hex-dump viewer (`dialog_uhf_memory_dump.xml`) showing EPC + TID + USER banks as a labeled monospace hex+ASCII dump (16 bytes per row, word-grouped). Reads via `readWait` on Bluetooth readers, `readEvent` fallback on RE_SERIAL. Header subtitle shows the chip identified from the TID (Impinj M730 / EM EM4425 / etc.) so the user sees which silicon is being talked to.
- **NDEF Browser dialog.** Read Memory on an NFC row now opens an NDEF-records viewer (`dialog_ndef_browser.xml`) showing UID, tag type, manufacturer hint, and the cached NDEF URI record. Short-circuits the UHF SDK access path (no inventory stop, no `readWait`).
- **Inline single-tag action bar (table/list view).** When a filter narrows the visible list to exactly one tag in TABLE view, a two-row bar of equally-sized buttons appears between the tag list and the Start/Stop/Clear footer. Row 1: Locate · Read Logger · Read. Row 2: Modify · Open URL · EM4425 URL · EM4425 DPP Write. Each button calls the same `TagActionListener` methods as the long-press popup. Hidden in card/grid view (card rows already carry their own per-row actions). Row 2 auto-collapses when none of its buttons are applicable (NFC rows).
- **"Isolate" popup-menu entry.** Long-pressing any tag row (UHF or NFC) shows a new "Isolate" option that copies the tag's EPC into the main-screen display filter — list narrows to just that row, inventory enters EPC-only speedup mode. Available in all 8 locales.
- **NDEF URL line in tag rows.** New 3rd sub-line below EPC + TID, prefixed `URL:` in primary-green monospace. Populated when `tagInfo.nfcUrl` is set (NFC rows from the cached NDEF tap; UHF rows after the EM4425 URL action). Table view renders the URL **full-width at the row root** (below the EPC + columns row) so long URLs aren't middle-ellipsized at 180 dp.
- **SELECT wake-up sweep before Read Logger / Read Memory.** New `runWithSelectWakeUp` helper runs a brief inventory (≤ 2.5 s, polls `tagsMap` for a count delta on the target EPC) immediately before any access read. Re-singulates the tag so the subsequent `readWait` doesn't `NO_RESPONSE_FROM_TAG` after a previous read session has let the Impinj M730 / EM4425 session flag lapse. If the sweep can't see the tag, surfaces the same "out of range" snackbar that the old synchronous freshness gate used to surface — and skips the doomed 3×3 NO_RESPONSE retry storm that wasted ~30 s.
- **Trigger-abort override on Tag Locate dialog.** Mirrors `OpusReadDialogHelper.installTriggerAbortOverride`. Hardware trigger press during a Tag Locate session now calls `stopLocating()` (which dismisses the dialog), instead of leaking through to `TriggerController` and starting a second inventory on top of the locate's running inventory.
- **`MqttManager.setNdefUrl(epc, url)` API + `url` field on every UHF SCAN.** New per-EPC map `ndefUrlByEpc` (cleared on broker disconnect) populated by the EM4425 URL / DPP Write actions. `createTagDataJson` writes `"url": <cached>` on every UHF SCAN payload, matching the NFC publish path's shape so the backend can bind a single `url` column across UHF + NFC scans.
- **MQTT Settings — master switch with mutually-exclusive pairing buttons.** Simple-mode card now has a SwitchMaterial "MQTT Configuration: No / Yes" header. Toggling OFF calls `MqttManager.wipeCredentials()` (host, username, password, topicId wiped); next ON requires re-pairing. Scan QR and Qdat.io Demo buttons split 50/50 below; whichever path supplied the active credentials renders at full opacity, the other at alpha 0.4. Persisted via new `KEY_PROVISIONING_SOURCE` (QR/DEMO/null) written by `applyQrSettings`/`applyDemoSettings`, cleared by `wipeCredentials`.
- **MQTT Settings — "Ignore Remote Commands" master + per-command list (Advanced).** Replaces the earlier "Enable Remote Commands" checkbox with a SwitchMaterial master toggle and (when master is off) 11 per-command switches generated from `MqttCommand.Type` values. New `KEY_IGNORED_COMMAND_TYPES` SharedPreferences key stores ignored types as a comma-separated string. Filter applied in `MqttCommandRouter.onMessageReceived` after parsing so the log surfaces which verb was suppressed.
- **MQTT Settings — auto-save on advanced-mode field focus-loss.** All 8 EditTexts (server, port, user, pass, topicId, topicScan, topicLogger, batchInterval) + `cbBatchEnabled` persist immediately via new `saveAdvancedFieldsFromForm()` helper. Per-field validation falls back to last-valid value rather than zeroing out unrelated fields.
- **MQTT Settings — `MqttManager.ACTION_MQTT_CONNECTION_STATE` broadcast** fires on every connection-state transition (connectComplete, connectionLost, disconnect, connect-onFailure). `MqttSettingsFragment` listens and refreshes the status line so it turns green on actual broker connection without polling.
- **Cloud-icon info dialog — "Try Demo (tapdpp.qdat.io)" button.** New `dialog_mqtt_info.xml` custom body. Enterprise-only (gated by `FEATURE_MQTT`). Provides the demo-provisioning path even when MQTT is already enabled, in addition to the disabled-state setup dialog.
- **Tag Locate — mute Tag-Read beeper + configurable proximity volume.** Two new App Settings → Tag Locate controls: "Mute the Tag Read beeper during locate" (default on) and a Low/Medium/High proximity-volume spinner. `TagLocateHelper` reads both at session open, saves and restores the reader's `BEEPER_VOLUME` for the duration, and recreates the `ToneGenerator` with amplitude 33/66/100 from the volume pref.
- **App General Settings — DataWedge description.** A grey 12sp description appears below the "Capture barcode scans into the EPC filter (DataWedge)" checkbox when checked, explaining broadcast delivery + TC22R RFID-input suppression.
- **General RFID Settings — DND warning Toast.** When the Beeper Volume picker is set to Medium or High and the device is in Do Not Disturb / silent / vibrate / stream-volume zero, a Toast informs the user. Uses read-only `AudioManager` / `NotificationManager` APIs; no new Android permissions.

### Changed
- **Read Logger / Read Memory no longer auto-restart inventory on dismiss.** When the user opens these flows from the main screen (with inventory running), inventory is force-stopped at the click handler (`forceMarkInventoryStopped` flips the UI button bar instantly). On dialog dismiss, inventory stays stopped — user manually restarts via the Start button or trigger. Resolves the "lost control of inventory" UX where dismissing the Read Logger silently re-armed it.
- **Read Memory dispatch by tag type.** OPUS tags → existing OPUS Status / ARM dialog (unchanged). Non-OPUS UHF → new UHF Memory Bank Browser. NFC → NDEF Browser. NFC short-circuit at the click handler skips inventory-stop and UHF SDK access entirely.
- **Tag-Type Identifier — EM4425 TMN range widened.** `E280B11` (TMN 0x110–0x11F) now identifies as "EM EM4425" instead of generic "EM Micro" — covers the V1.2 / V1.3 silicon variants beyond the V1.2 prefix `E280B110` previously hard-coded.
- **NFC_V row UID display uses `tagInfo.displayUid`** (MSB-first / printed form) instead of `tagInfo.epc` (wire-order, LSB-first for ISO 15693). `epc` keeps wire-order bytes so MQTT + DB keys are stable per air interface; only the row label flips to printed form.
- **Read Logger redundant inventory stop removed.** `readCompleteTagDataAndShowDialog` now skips `safeStopInventoryForRead()` when `rfidInventoryManager.isRunning() == false`. Previously it ran unconditionally after the wake-up sweep had already stopped inventory, adding ~300 ms between SELECT and `readWait` — long enough for the Impinj M730's session flag to lapse and the read to NO_RESPONSE. Net: ~700 ms gap instead of ~1.2 s.
- **EM4425 NDEF read buffer bumped 24 → 32 words (48 → 64 bytes).** Covers full DPP-write payloads (`https://tapdpp.qdat.io/<24-char EPC>` = 47 chars after prefix). 24 words truncated payloads longer than ~30 ASCII chars; 32 words fits comfortably with headroom for a future longer URL.
- **MQTT subscription decoupled from `commandEnabled` flag.** Subscription state is now always-on whenever MQTT is enabled + unified topic mode is active. The `commandEnabled` flag became a downstream filter at `MqttManager.messageArrived` (drops messages silently) rather than a gate at subscription time. `MqttManager.connectComplete` outer condition dropped `commandEnabled`; `RfidService.initializeMqttCommandSubscription` Gate A dropped the same check. Net effect: re-arm broadcast fires whenever `isUnifiedTopicMode() && commandRouter == null`, which is strictly broader and simpler.
- **MQTT Advanced Mode toggle moved inside the simple-mode card.** All MQTT config UX now lives in one card with the master-switch greying applying uniformly via shared `applyEnabledState` helper.
- **Pairing-row buttons relabeled and resized.** "Scan QR Code" → "Scan QR" (8 locales updated to shorter text). New Qdat.io Demo button labelled "Qdat.io Demo" at `12sp` so it fits the half-width slot. Both buttons share a 50/50 row directly under the master switch.
- **MQTT Settings — Test Connection button hidden in simple mode.** The master switch + Scan QR / Qdat.io Demo + auto-save already cover the simple-mode flow; the button only reveals when Advanced Mode is on, where it doubles as the apply-and-connect action.
- **MQTT Settings — Test Connection in advanced mode is a no-op when fields match persisted state and broker is connected.** New equality check across 10 connection-relevant fields short-circuits the `saveSettings → disconnect → reconnect` cycle when nothing has changed; `mqttManager.connect()` then short-circuits internally with `isConnected()` and surfaces the "Connected" toast without disrupting the session.
- **MQTT Settings — status line moved above Test Connection.** Always visible (placeholder "Not tested" by default); turns red "Disconnected from broker" when master switch goes off; green on successful connect via the new broadcast.
- **MQTT host display — "(TapDPP.QDat.io Demo Mode)" tag on a second line.** Only when the provisioning source is `DEMO` (not just because the host happens to equal `mqtt.tapdpp.qdat.io`), so QR-paired sessions to the same domain show the host on one line.
- **Legacy Topics card hides entirely in unified-topic mode.** Previously only its inner labels and EditTexts toggled GONE; the empty card frame remained between Remote Commands and Batch Publishing. Now the whole `MaterialCardView` (new `card_legacy_topics` id) toggles.
- **PredictApiSettingsFragment — auto-save on field change.** The previously-dead `applySettings()` method is bypassed by per-field focus-loss listeners on URL/key + on-change listener on the enabled checkbox. Also removed `testApiConnection`'s force-write of `api_enabled=true` which made unchecking impossible.
- **MQTT Settings — `commandEnabled` default flipped from `false` to `true`.** Fresh installs now process incoming commands by default; the new "Ignore Remote Commands" toggle (Advanced) is the explicit opt-out. Existing installs that explicitly set the flag retain their behavior under the new label.

### Fixed
- **Read Logger / Tag Locate trigger semantics.** Hardware trigger during Tag Locate now stops the locate session (previously had no effect — the override was only wired on Read Logger). Hardware trigger during Read Logger now stops the read and leaves inventory stopped (previously the dismiss path silently re-armed inventory).
- **EM4425 NDEF parser strict-bounds bail-out.** Previously, `parseEm4425NdefUri` returned `null` when the TLV-declared length ran past the buffer (`dataStart + len > bytes.length`) — a short read truncated the URL but the parser bailed entirely. Now clamps to `bytes.length` and lets `decodeNdefUriRecord` (also bounds-tolerant) return whatever URL bytes the wire actually carried.
- **EM4425 NDEF parser CC header check too strict.** Required exact `E1 43` magic per the datasheet example, but real silicon writes other CC bytes (e.g. `E1 40 17 00`). Now accepts any `E1 ??` magic and just skips the fixed 4-byte CC header so the TLV walker starts at the right offset.
- **EM4425 DPP Write NO_RESPONSE_FROM_TAG when overwriting existing NDEF.** Writing the URL onto a USER region that already contained an NDEF Message TLV consistently failed with `RFID_ACCESS_TAG_WRITE_FAILED / access no response from tag`, despite the wake-up sweep seeing the tag and despite an earlier-session write succeeding on the same chip. Workaround: pre-clear the 26-word target region with `0x0000` before writing the real NDEF. Clean USER region writes succeed reliably.
- **Inventory not immediately stopped when transitioning to Read Logger / Tag Locate.** Click handler now calls `forceMarkInventoryStopped()` synchronously on the UI thread so the Start/Stop button bar flips instantly, in addition to the existing async SDK stop. User no longer sees inventory still "running" while the dialog is opening.
- **Stale Read-Logger freshness gate replaced by wake-up sweep result.** The synchronous `isTagWithinReadFreshness` (10 s window) rejected legitimate retries once the previous inventory's `lastInventoryTime` aged out — even when the tag was still physically present. Now the wake-up sweep itself is the freshness test: if it can see the tag in ≤ 2.5 s, the read goes through; if not, the same "out of range" snackbar surfaces.
- **Beeper volume preference clobbered on every reader connect.** `ReaderConnectionManager.connect()` was unconditionally calling `setBeeperVolume(QUIET_BEEP)` at the end of its connection setup, overriding the user's RFID Settings → General → Beeper Volume choice on every cold start and every Bluetooth auto-reconnect. The hardcode predated the user-facing picker (comment was "// Disable beeper"). Fix reads the same `RFIDGeneralSettings.beeper_volume` key the picker writes, defaults to Medium (position 2). Full write-up in `docs/NOTE-FIX-BEEPER-VOLUME-CLOBBER.md`.
- **`MqttSettingsFragment.testMqttConnection` hardcoded `1000ms` for batch interval** instead of reading `etBatchInterval`, silently dropping user-typed values when saving via the advanced-mode Test Connection path. Now reads the field and validates against the 100–60000ms range.
- **Demo provisioning entry point inside the Settings fragment** (right-half button on the pairing row) wasn't wired. Now delegates to the same `DemoProvisioner.provision(...)` → `applyDemoSettings(...)` flow as `MainActivity.runDemoProvision`, with the same error-message mapping.

### Build
- **Version bump**: `2.0.10` → `2.0.11` (versionCode 66 → 67).

### Documentation
- `docs/DESIGN-MQTT-COMMAND-CHANNEL.md`: new design-rationale document covering the rearm-broadcast pattern (why it exists, when it fires, what it mitigates, pitfalls, benefits, stability impact, code-complexity assessment, future considerations).
- `docs/NOTE-FIX-BEEPER-VOLUME-CLOBBER.md`: new fix-note covering the diagnosis, root cause (stale `// Disable beeper` hardcode), the fix, verification, risk + rollback notes.

## [2.0.10] - 2026-05-28

### Changed
- **MQTT-unconfigured toolbar icon no longer blinks.** The red-cloud ↔ red-?-mark 600 ms alternation (driven by a `Handler`/`Runnable` in `MainActivity`) is replaced by a single static `ic_mqtt_question` drawable — a red cloud with a white question mark, colors baked in. The toolbar icon is recolored by one `setImageTintList()`, which can't paint two colors, so `startMqttPromptAnimation()` now clears the tint (`setImageTintList(null)`) and shows the drawable as-is. The four animation fields (`MQTT_PROMPT_INTERVAL_MS`, `mqttPromptHandler`, `mqttPromptRunnable`, `mqttPromptShowingQuestion`) and the `onPause()` stop-call were removed. `stopMqttPromptAnimation()` still restores the plain `ic_mqtt` so the enabled-state green/orange/red tint logic in `updateMqttStatus()` is unchanged.
- **Release Notes moved from the toolbar overflow menu to the About dialog.** The `action_release_log` overflow item and its `MainActivity` handler were removed; `dialog_about.xml` gains an outlined "View Release Notes" `MaterialButton` (with the `ic_history` icon) directly above Close, wired in `showAboutDialog()` to dismiss the dialog and launch `ReleaseLogActivity`. `ReleaseLogActivity` itself is unchanged (still reads `assets/CHANGELOG-APP.md`).

### Added
- **`ic_mqtt_question.xml`** vector drawable — red cloud (`@color/error`) plus a white `?` glyph scaled ~0.5 and centered into the cloud body via a `<group>`.
- **`view_release_notes` string** ("View Release Notes") added to `values/` and all 7 other locales (`Voir les notes de version` (fr), `Versionshinweise anzeigen` (de), `Ver notas de versión` (es), `Visualizza note di rilascio` (it), `リリースノートを表示` (ja), `릴리스 노트 보기` (ko), `查看发行说明` (zh-rCN)).

### Removed
- **`ic_question_mark.xml`** — its glyph is now inlined in `ic_mqtt_question.xml`; the blink was its only consumer.

### Fixed
- **MQTT command subscription never armed when commands were enabled at runtime.** The QDatDroid client connected to the broker, published `/data` and `/pong` heartbeats, but **never sent a `SUBSCRIBE`** for its `devices/<topicId>/command` topic. As a result, the TAP DPP ingester's `TAG_REGISTERED` reply commands (issued for every published scan) piled up at the broker undelivered — confirmed end-to-end with `gcloud logging`. Root cause: `RfidService.initializeMqttCommandSubscription()` is idempotent and only invoked from `onCreate` + the reader-connect success path; both ran *before* the user enabled commands in MQTT Settings. The settings-save flow reconnected MQTT, but `MqttManager.connectComplete` required `commandRouter != null` to subscribe — and the router stayed null. Fixed by broadcasting `ACTION_MQTT_COMMAND_REARM` from `connectComplete` when the router is null; `RfidService` re-runs the initializer on receipt. Full write-up in `docs/NOTE-FIX-MQTT-COMMAND-SUBSCRIBE-EN.md` and design rationale in `docs/DESIGN-MQTT-COMMAND-CHANNEL.md`. (Landed on `main` as PR #5 / commit `93fd754`.)

### Build
- **Version bump**: `2.0.9` → `2.0.10` (versionCode 65 → 66).

## [2.0.9] - 2026-05-27

### Changed
- **In-app "Release Log" renamed to "Release Notes"** across all 8 locales (`tab_release_log` and `release_log_unavailable` string values updated; resource keys kept as-is to avoid churn in Java callers and menu XML). Translated to `Notes de version` (fr), `Versionshinweise` (de), `Notas de versión` (es), `Note di rilascio` (it), `リリースノート` (ja), `릴리스 노트` (ko), `发行说明` (zh-rCN).
- **In-app Release Notes screen now reads a separate user-facing changelog.** `ReleaseLogActivity.ASSET_PATH` switched from `CHANGELOG.md` to `CHANGELOG-APP.md`; `:app:syncChangelogAsset` Gradle task now sources `rootProject.file('CHANGELOG-APP.md')`. The developer-oriented `CHANGELOG.md` (this file) is no longer bundled into the APK — no class names, library names or file paths leak into the UI.

### Added
- **`CHANGELOG-APP.md` at the repo root** — user-facing release notes covering 2.0 → 2.0.8 in full detail and 1.0.2 → 1.9.9.5 condensed (38 versions total). Written with no class / function / library / file references. This file is the new source-of-truth for the in-app Release Notes screen.

### Build
- **Version bump**: `2.0.8` → `2.0.9` (versionCode 64 → 65).
- **`.gitignore`** updated: `app/src/main/assets/CHANGELOG-APP.md` added; legacy `app/src/main/assets/CHANGELOG.md` entry kept so it falls out of working trees that built before the rename.

## [2.0.8] - 2026-05-27

### Build
- **Version bump**: `2.0.7` → `2.0.8` (versionCode 63 → 64). Re-release to refresh the in-app Release Log asset after the 2.0.7 CHANGELOG section was consolidated from a duplicated post-merge layout (two parallel feature branches both bumped to 2.0.7, then merged — leaving two `Fixed` and two `Build` subsections under the same heading) into a single ordered Added / Changed / Fixed / Tests / Build list. No functional code changes.

## [2.0.7] - 2026-05-27

### Added
- **In-app Release Log under Settings.** New "Release Log" tab in the unified `ApplicationSettingsActivity` ViewPager renders the project's `CHANGELOG.md` in a monospace scrollable view (ported pattern from tapdpp's `ReleaseLogScreen`). The CHANGELOG is copied from the repo root into `app/src/main/assets/CHANGELOG.md` by a new `:app:syncChangelogAsset` Gradle task that runs as a `preBuild` dependency, so the in-app log never drifts from the source of truth.
- **MQTT-icon choice dialog.** When MQTT is not configured, tapping the red-blinking toolbar icon now opens a choice dialog with two options: **Pair via QR code** (routes to `ApplicationSettingsActivity` with `target_section=mqtt`, the existing path) and **Try Demo (tapdpp.qdat.io)** (enterprise flavor only; gated by `BuildConfig.FEATURE_MQTT`). The demo path prompts for an email, calls `https://api.tapdpp.qdat.io/api/v1/demo/self-provision-reader`, applies the returned MQTT credentials via the new `MqttManager.applyDemoSettings(...)`, and auto-connects.
- **`DemoProvisioner`** (`mqtt/DemoProvisioner.java`). Java port of the helipass self-provisioning client. Context-free for unit-testability — HTTPS POST with typed `ProvisionException.Reason` (`NOT_DEMO_MODE` / `AT_CAPACITY` / `RATE_LIMITED` / `BAD_REQUEST` / `UNEXPECTED` / `MALFORMED_RESPONSE`) lets `MainActivity` map to localized strings without coupling the network layer to Android resources. PII-safe logging (email length only, truncated device ID).
- **Encrypted MQTT credential storage.** New `mqtt/SecureMqttStorage` wraps `EncryptedSharedPreferences` (AES256-GCM, mirrors the existing `license/SecureLicenseStorage` pattern). One-shot migration on first open after upgrade copies plaintext `mqtt_settings` into the encrypted store, deletes the legacy XML, and sets a `_migrated_from_plaintext` guard so it never runs twice. Plaintext fallback on encryption failure keeps the app working on the rare device where EncryptedSharedPreferences cannot be created.
- **Offline queue dedup.** `MqttPublishQueue.enqueueUnique(epc, topic, payload)` strips prior same-EPC entries before append. Both `MqttManager.publishScanData` and `publishNfcScan` now use it, so a flaky link no longer accumulates 50× re-publishes of the same tag while waiting for `TAG_REGISTERED`.
- **MQTT reconnect watchdog** (5 s interval). Re-fires `connect()` when `isEnabled && !isConnected && !isConnecting && !intentionalDisconnect`. Catches the case where Paho's built-in auto-reconnect silently gives up after broker-initiated disconnects (revoked credentials, transient TLS failures). Cancelled in `connectComplete` and `disconnect()`.
- **Pre-connect network capability check.** `MqttManager.hasNetworkConnectivity()` calls `ConnectivityManager.getActiveNetwork().NET_CAPABILITY_INTERNET` before attempting connect; on false, surfaces a localized "no network — will retry when online" failure and arms the watchdog. Distinguishes user-side connectivity issues from broker-unreachable in the logcat trail.
- **Intentional-disconnect flag** suppresses Paho's noisy async `connectionLost` callback during user-initiated teardowns. Cleared 750 ms later via Handler. Also set when `connect()` swaps out a stale client.
- **Stale `connectionLost` suppression.** If Paho fires `connectionLost` while the new client has already reconnected, the callback is dropped instead of flipping the UI red.
- **`AuthFailureListener`** interface distinguishes credential-rejection failures (Paho reason codes 4/5/135 or message keywords matching auth verbiage) from network-class failures. Cause-chain walk down-votes the auth verdict when wrapped network exceptions are detected. Fires on the main thread; `MainActivity` listens and surfaces a "MQTT credentials rejected — re-pair via QR code" snackbar so users know to re-provision rather than retry indefinitely.
- **GPS passive-provider seed and subscription.** `GpsManager.startLocationUpdates()` now also queries and subscribes to `PASSIVE_PROVIDER` (free piggy-back on whatever location other apps are already requesting). First scan after a fresh install gets a location immediately when any other app has location active, instead of waiting for the first GPS or NETWORK fix.
- **`GpsDiagnostic`** structured GPS readiness snapshot (Java port of helipass `GpsDiagnostic.kt`). 6-state `Status` enum (`LIVE_READY`, `LIVE_WAITING_FIX`, `OVERRIDE_ACTIVE`, `PERMISSION_MISSING`, `PROVIDERS_OFF`, `NO_HARDWARE`) drives future UI surfaces; for now consumable via `GpsManager.diagnose(...)`. 5-minute default fresh-fix window.
- **`PhotonGeocoder`** zero-dependency utility class (`utils/PhotonGeocoder.java`). Forward + reverse address lookup via Photon (OpenStreetMap), `HttpsURLConnection`-only so it's usable from any flavor without dragging OkHttp transitively. The existing inline OkHttp-based Photon path in `GpsSettingsFragment` is preserved (it supports request cancellation that the synchronous helper would regress).

### Changed
- **Localization across 8 locales.** 18 new string keys for the demo dialog, error mapping, and reliability snackbars (plus 2 for the Release Log tab) added to `values/strings.xml`. Translations added to `values-de/`, `values-es/`, `values-fr/`, `values-it/`, `values-ja/`, `values-ko/`, `values-zh-rCN/` via the `rfid-translation-checker` agent — no TODO placeholders.

### Fixed
- **TID truncated to canonical 24 chars at the MQTT wire boundary.** Quick Scan was publishing the full 64-byte TID memory bank (128 hex chars) into the `tid` field of qdat.io scan payloads instead of the documented 12-byte canonical IC TID. `RfidInventoryManager` reads the full 64 bytes from the TID bank so `OpusTagDecoder` can parse logger config (words 0x08-0x1F), but the raw 128-char `tidHex` was being handed straight to `MqttManager.publishScanData()`. The qdat.io ingester writes whatever it receives verbatim, so backend tag rows ended up with 128-char TIDs and ~52 bytes of trailing bank memory after the real TID. Sliced at the wire boundary, not internally — `info.tid` stays full bank so OPUS / logger extraction paths still see `tid0x08to0x1F`. New `TagInfo.canonicalTid(String)` static helper backs the existing `getCanonicalTid()` instance method; all three wire sites (Quick Scan publish in `RfidInventoryManager`, batch / direct / outbox funnel in `MqttManager.createTagDataJson`, READ command response in `MqttCommandExecutor`) now route through the same expression. `HistoryExporter` local JSON export left untouched (app-internal format, not the qdat.io wire contract).

### Tests
- **`TagInfoCanonicalTidTest`** — 5 cases covering the exact 128-char ingester payload from the reported bug (primary fixture) plus null / empty / short / exact-24 edge cases. Verified on-device against an OPUS tag — broker ACKed with `TAG_REGISTERED` and `chip_type "Opus"`, confirming no regression on the live publish path.

### Build
- **Version bump**: `2.0.6` → `2.0.7` (versionCode 62 → 63).
- **`org.json:json:20240303`** added as `testImplementation` so `MqttPublishQueue`, `DemoProvisioner`, and other JSON-touching unit tests can exercise `org.json.JSONObject` without Robolectric or `returnDefaultValues=true`.
- **`:app:syncChangelogAsset` Gradle task** copies `CHANGELOG.md` from the repo root into `app/src/main/assets/CHANGELOG.md` before `preBuild`. The asset stays gitignored so the source-of-truth file remains the repo-root `CHANGELOG.md`.

## [2.0.6] - 2026-05-23

### Added
- **Unified Settings screen** consolidating what used to be four separate top-level menu entries (Application Settings, RFID Settings, NFC Settings, GPS Settings). A single `ApplicationSettingsActivity` now hosts every preference fragment behind a `ChipGroup` + `ViewPager2`. The `ChipGroup` wraps onto multiple lines (Material `singleLine="false"`) so all ~9 tabs stay visible without horizontal scrolling regardless of screen width; `ViewPager2` lets the user swipe left/right between sections. Chip ↔ page sync uses a `isSyncingTabs` flag to break the feedback loop. Tabs ordered by category: App (General, Display) → RFID → Hardware (NFC, GPS) → Cloud (MQTT, OPUS, Predict API) → History pinned last.
- **Tag Access Password feature.** New `SecurePasswordStorage` (`EncryptedSharedPreferences` AES256, prefs file `rfid_password_prefs`) + static `AccessPasswordProvider` initialised in `Application.onCreate()`. Replaces the 45+ hardcoded `setAccessPassword(0)` call sites across `TagMemoryManager`, `OpusConfigurationManager`, `RfidInventoryManager`, `OpusLoggerManager`, `OpusArmDialogHelper`, `OpusReadDialogHelper`, `TagMemoryWriteDialogHelper`, `MainActivity`, and `MqttCommandExecutor` — fully backwards-compatible (returns `0L` when no password configured). The `TagMemoryWriteDialog` gains three tabs: **Memory** (existing flow), **Password** (read RESERVED bank, change access/kill password, optional save-as-default), **Lock** (per-bank lock privilege spinners + danger-zone Kill Tag with double confirmation). Settings screen exposes a "Tag Access Password" card with `Save` / `Clear` and 8-hex validation.
- **Lock + Kill operations** exposed in the RFID abstraction. `RfidReaderPort` extended with `LockParams` + `Lockable` + `LockPrivilege` interfaces and `lock()` / `kill()` methods. `ZebraRfidReaderAdapter` implements them via `TagAccess.lockWait()` / `killWait()`. `RfidController` exposes `lockTag()` / `killTag()` (queued through the existing state machine, retry on OPERATION_IN_PROGRESS). `TagMemoryManager` exposes synchronous `applyLocks()`, `killTag()`, `readReservedBank()`, `writeAccessPassword()`, `writeKillPassword()`.
- **Reader-specific RFID Settings** restored as its own toolbar item. After the Settings unification, Antenna + Regulatory (which both require a live reader connection) were re-extracted into a dedicated `RFIDSettingsActivity` with `TabLayout` + `ViewPager2` (2 tabs) and the same "Save and go to Main screen" bottom-button UX as the unified Settings. Menu item gated on `isConnected` so it's greyed out without a reader.
- **Auto-configure regulatory region on factory-fresh readers.** Restored historical behaviour lost during the `RfidService` refactor: when `reader.connect()` throws `Region Not Set`, `RfidService.autoConfigureRegionIfNeeded()` enumerates `SupportedRegions`, picks `CAN` (preferred) or `USA` (fallback), applies it with channel list + frequency hopping enabled, and retries the connect transparently. Conservative trigger — only fires on the connect-time exception, never proactively touches a reader that connected successfully.
- **MaterialCardView treatment** across every Settings fragment. Each section in App General, App Display, RFID, MQTT, Predict API, Antenna and Regulatory layouts now lives inside its own card (12dp radius, 1dp divider stroke, 14dp internal padding, 12dp inter-card spacing), matching the existing NFC/GPS/History pattern.
- **RF Mode display refactored from horizontal table to per-mode cards.** The old 5-column table (Index/TARI/BDR/Mod/FLM) caused horizontal scroll on narrow screens. Each `RFModeTableEntry` is now rendered as a `MaterialCardView` with vertical label/value rows. Active mode highlighted with a 2dp green stroke + "Active" badge; best-match mode with amber stroke + "★ Recommended" badge.
- **Database statistics in History tab** restyled as "label | value" rows with the value right-aligned and bold. `labelOnly()` helper extracts each label by splitting the existing localized "Label: %s" format string on `:`, so the change works across all 8 locales without duplicating string resources.

### Changed
- **Tab labels shortened**: `Application Settings` → `Settings`, `RFID General` → `RFID`, `NFC Settings` → `NFC`, `GPS Settings` → `GPS`, `OPUS Arming` → `OPUS`. Translated across all 8 locales (EN, FR, ES, IT, DE, JA, KO, ZH-CN).
- **Per-fragment Apply / Reset buttons removed** from App General, App Display, RFID, MQTT and Predict API layouts. The Settings activity's single "Save and go to Main screen" button now drives `saveSettings()` on every attached `SettingsSaveable` fragment in one tap — eliminates the inconsistent UX where some sections had Apply and others didn't.
- **`SettingsSaveable` interface promoted** from a nested interface inside `RFIDSettingsActivity` to a top-level `com.meerv.qdat.fragments.SettingsSaveable` so multiple hosting activities can implement the save-all pattern without duplicating the type.

### Removed
- **`RFIDSettingsPagerAdapter`** and the legacy `section_application_settings.xml` accordion layout — superseded by the generic `SettingsPagerAdapter` (lambda-friendly `FragmentSupplier` list).
- **Standalone `NfcSettingsActivity` and `GpsSettingsActivity` layouts (`activity_nfc_settings.xml`, `activity_gps_settings.xml`)** — content migrated to `NfcSettingsFragment` / `GpsSettingsFragment` hosted by the unified Settings screen. `GpsSettingsFragment` carries the full OSMDroid `MapView` + Photon reverse-geocode flow with proper lifecycle (`onResume`/`onPause`/`onDestroyView` → `MapView.onResume/onPause/onDetach`).

### Fixed
- **Crash opening Settings → History.** `AppHistorySettingsFragment.labelOnly()` was passing an empty `String` placeholder into a `getString(R.string.history_stats_tag_count, …)` call whose format spec is `%1$d`, triggering `IllegalFormatConversionException: d != java.lang.String` and force-finishing the activity. Fixed by reading the unformatted pattern via `getResources().getText(resId).toString()` and stripping at `:` — no formatting ever runs, so the placeholder type no longer matters.
- **Inventory failed to start after the auto-region patch.** The first iteration called `autoConfigureRegionIfNeeded()` unconditionally after every successful `reader.connect()`, which on already-provisioned readers reading region `"NA"` (a legitimate "North America" code in some Zebra firmware) rewrote the regulatory config mid-connect and left the SDK in a state where `reader.Actions.Inventory.perform()` silently produced nothing. The proactive call is now removed; auto-config only fires from the connect-time exception handler when the reader truly has no region set. The `"NA"` value is no longer treated as "unset" — only `null`/empty triggers writes.

### Build
- **Version bump**: `2.0.5` → `2.0.6` (versionCode 61 → 62).

## [2.0.5] - 2026-05-22

### Added
- **Flashing MQTT-prompt trail surfaces buried configuration.** When `MqttManager.isEnabled()` is false, three coordinated cues now nudge the user toward setting it up:
  1. **Toolbar MQTT icon** stays visible (was previously `GONE`) and alternates red MQTT logo ↔ red `?` every 600 ms via a new `ic_question_mark` vector drawable. Driven by `MainActivity.startMqttPromptAnimation()` + `stopMqttPromptAnimation()`; the `Handler` is cancelled in `onPause` so the Activity isn't pinned in memory, and restarted from `onResume` via the existing `updateMqttStatus()` path. Idempotent.
  2. **Info dialog's Settings button** now passes `target_section="mqtt"` to `ApplicationSettingsActivity`. The activity expands the matching section instead of the default first one and `smoothScrollTo`s the `NestedScrollView` (newly given `@id/settings_scroll_view`) to its top — wrapped in a `View.post` so the scroll happens after the layout pass.
  3. **Scan-QR button** inside `MqttSettingsFragment` pulses (`AlphaAnimation 1.0 ↔ 0.35`, 600 ms, infinite reverse) while MQTT remains disabled. Stops on first tap (user followed the hint) or on fragment pause; restarts in `onResume` if still unconfigured.

### Build
- **Version bump**: `2.0.4` → `2.0.5` (versionCode 60 → 61).

## [2.0.4] - 2026-05-22

### Added
- **Inline OPUS instant-temperature pill in the Read Logger Status tab.** Wider chip (180 dp min width, 16 sp text) added to `status_chip_group_alarms` in `OpusReadDialogHelper`. Reads USER word 2 via `OpusTemperatureDecoder.decodeUserWord2Temperature(word2)` whenever the periodic `refreshTagData` loop pulls a USER read (the same loop that yields the reliable battery voltage). Honors the user's `temperature_unit` preference via `TemperatureFormatter.formatWithUnit`. Alarm-red color when a `pcDecoded.alarmTemperature` is asserted, neutral green otherwise. Pill stays present (showing "Temp: --") even when the value is the OPUS uninitialized-sensor default so the user can tell a read attempt was made.
- **Triple-stop recovery gesture.** Three taps on the Stop button within 5 seconds force-disconnects the reader, with snackbar `stop_triple_disconnect_toast`. New `MainActivity.detectTripleStopForDisconnect()` (ring buffer of N tap timestamps); new `MainActivity.disconnectReader(boolean force)` overload that bypasses the `rfidService.isConnected()` gate so the cleanup still runs when the SDK has given up on the radio. Press counter is reset after a successful detection so the next gesture starts fresh.
- **`Read OPUS Instant Temp` toggle in RFID Settings → General.** Pref key `read_opus_instant_temp`, default OFF after the background worker proved to dead-lock the reader against the auto-restarting `readEvent` and user-triggered Read Logger / ARM dialogs. Wired through `RfidService.setReadOpusInstantTemp` and `RfidInventoryManager`; restored at app start in `MainActivity.onServiceConnected`. Keeps the door open for a redesigned safer worker (task #43).
- **`Debug RFID toasts` toggle in RFID Settings → General.** Pref key `debug_rfid_toasts`, default OFF. New `com.meerv.qdat.utils.RfidDebugToaster` utility surfaces each high-value Zebra SDK call + result as a rolling Toast (cancels the previous so they don't pile up). `Log.d` forwarding to logcat under tag `RfidDebugToaster` happens unconditionally so the trail is captured even without the visible Toast. Hooked into `RfidInventoryManager.startInventory` / `safeStopInventoryForRead` / `forceReleaseSdkLock` (stopAccess and Inventory.stop both passes), `RfidService.disconnectReader` + `reader.disconnect`, and `MqttCommandExecutor.executeGetVersion`.
- **EPC-only speedup mode** in RFID Settings → General. When ON and no UI filter is active, inventory runs `MemoryBankConfig.epcOnly()` with the OPUS-prefix PreFilter suppressed (see new `RfidInventoryManager.setSuppressOpusPreFilter`) — every tag in range becomes visible at full throughput. As soon as the user types in the EPC/TID search box or picks a chip from the type spinner, `RfidService.applyEffectiveMemoryBankConfig()` transparently switches back to full scan (TID + PC via `readEvent`) so the filter can match by chip type or partial TID. Clearing the box and resetting the type filter to "All Types" restores the speedup. `RfidService` tracks `userSelectedMemoryBankConfig` separately so non-speedup sessions keep whatever the user picked from the Memory Bank menu. Default OFF.
- **Per-OPUS-tag rolling worker (default OFF)**: `RfidInventoryManager.enqueueOpusUserReadIfNeeded` + `opusUserReadDrainRunnable` queue USER reads for newly-identified OPUS EPCs and drain in batches after a 2-second debounce. `safeStopInventoryForRead` pauses inventory, the batch executes via `TagAccess.readWait` (USER bank, words 0-3), `applyOpusUserData` populates `TagInfo.userData` + rebuilds `OpusParser.Information` so the UI sees temperature live, then `safeRestartInventoryAfterRead` resumes. EPCs marked done in `opusUserReadDone` never re-trigger. Currently disabled by default — see `Read OPUS Instant Temp` above.
- **TID + CIN static entries** at the top of the chip-type spinner (positions 1 and 2), pinned ahead of the dynamic chip-discovery list. Sentinels `TagsAdapter.TAG_TYPE_FILTER_TID` and `TAG_TYPE_FILTER_CIN`. `matchesTagType` short-circuits: TID matches everything (acts as a mode shortcut — selecting it engages full-scan via `pushInventoryFilterState` so chip-type entries below start populating); CIN narrows to rows where `tag.cinBrand != null` (decoded from PC+EPC by `RainCinIdentifier.identify`) without changing inventory mode (PC is captured on every scan regardless).
- **Global cell-font slider in column long-press dialogs.** New `tableBaseFontSp` field on `TagsAdapter` (default 11 sp, range 8–18 sp) plus an `appendFontSlider` helper that adds a labeled SeekBar to both `showEpcWidthDialog` and `showColumnWidthDialog`. The slider value is shared across every column's dialog. Acts as a ceiling on the existing width-driven auto-scale: at default width or wider the cell uses the slider value; narrowing a column still falls back to the legibility floor so text never clips. Persisted as float pref `tag_table_base_font_sp` in AppSettings.
- **Persistent display-filter gate on MQTT publish.** New `MqttManager.setEpcDisplayFilter(String)` + `blockedByDisplayFilter(epc, tid)` short-circuit `publishScanData` / `publishNfcScan` when the EPC/TID don't contain the user's typed substring. `MainActivity`'s search-box TextWatcher pushes the query down whenever the user is in filter mode (search mode clears it). Broker only sees what the UI shows.
- **TID always under EPC in TABLE view.** Layout already had `tv_tid` immediately under `tv_epc` (`item_tag_table.xml`), but the binder was gating it on `showColumnTid` — a field that no UI affordance could turn back on, since the column-config dialog deliberately omits TID from its checkbox list. Every Apply on that dialog silently reset `table_col_tid` to false, leaving users permanently TID-less. Gate removed; TID display now mirrors the GRID view unconditionally. Pending placeholder updated to `"(pending until filter set)"`.
- **`RfidService` caches reader metadata at connect.** `cacheReaderMetadata()` reads model, serial, manufacturer, antenna count, firmware version, and regulatory region **once** right after `reader.connect()` succeeds (before inventory starts, while we own the SDK lock). Cached as volatile fields, exposed via `getCachedReaderModel()` etc., and cleared in `disconnectReader()`. MQTT GetVersion's `executeGetVersion` rewritten to read entirely from this cache — the diagnostic ping now makes **zero SDK calls** on the hot path. Eliminates the periodic backend GET_VERSION → `getRegulatoryConfig` contention that intermittently wedged inventory into `RFID_OPERATION_IN_PROGRESS` (we'd see "GetStatus SetRegulatory" timeouts followed by inventory failing for the rest of the session). The pre-existing user-screen sites (`ReaderInfoFragment`, `GeneralFragment`, `RegulatorySettingsFragment`) still call the live SDK because their pages pause inventory anyway.
- **Always-emit four binding keys** in scan payloads: `tag_type`, `chip_manufacturer`, `cin_brand`, `nfc_ic_manufacturer` are present on every tag entry in `data[]` — with explicit `null` when the value isn't applicable (e.g. `nfc_ic_manufacturer` on a UHF scan, `chip_manufacturer` on an NFC scan). Backend now binds unconditionally. Stress fixture (`dist/qdat-io-stress-payloads.json`) refreshed to include the keys on all 333 tag entries across 331 payloads; generator (`scripts/generate_qdat_io_tag_types.py`) updated to mirror the new shape.
- **MQTT command subscription wired at service create**, not at reader-connect. NFC-only sessions (no UHF reader) now respond to backend `GET_VERSION` / `PING` correctly — the executor was previously only attached after `connectToReader()` succeeded. `RfidService.initializeMqttCommandSubscription()` is now idempotent and called from `onCreate`; cleanup is in `onDestroy` only (no longer torn down on every UHF disconnect, so disconnecting the reader doesn't silently break NFC-mode command response).

### Changed
- **`RfidService.startInventory` runs on a background thread.** The underlying `RfidInventoryManager.startInventory()` can sleep up to ~9.5 s on its retry backoff (1.5 + 3 + 5 s) when the SDK returns `OPERATION_IN_PROGRESS`. Blocking the UI thread for that long was producing 5.7-second freezes and 340-frame drops after Read Logger / ARM dialogs closed. Six main-thread direct calls to `rfidInventoryManager.startInventory()` in `MainActivity` also wrapped in `new Thread(...)`. The seventh site (line ~6186) was already inside a worker thread and left as-is.
- **Inventory-line `Temp:` suffix removed from GRID + TABLE.** The single-USER-read approach catches the real temperature only ~1/3 of the time on OPUS (per OPUS Studio for Windows the chip rotates its USER measurement through battery / temperature / on-chip RSSI on successive reads). `opusTempSuffix` now returns empty unconditionally. Instant temperature is shown exclusively in the Read Logger Status tab.
- **Temperature column retired in TABLE view.** Replaced by the inline OPUS suffix on the TID sub-line. Both the cell (`tvTemperature`) and the header (`header_temp`) are force-hidden by the binder/column-reorder method; "temp" is also skipped in the column-config dialog so users can't toggle a column that no longer renders.

### Fixed
- **NFC mode now responds to MQTT `GET_VERSION`.** Pre-fix: `RfidService.initializeMqttCommandSubscription` was wired exclusively to the UHF reader-connect success path, so NFC-only sessions had no command router attached and every backend command was silently dropped at the `messageArrived` short-circuit (`commandRouter != null` check). Post-fix: subscription is attached at service start, regardless of whether a UHF reader ever connects.
- **Stop button click is no longer "undone" by the worker.** When the optional OPUS instant-temp worker was enabled, pressing Stop during a worker drain cycle could leave inventory restarted by the worker's `safeRestartInventoryAfterRead`. The user-triggered Stop now flushes the worker's pending queue and cancels in-flight drain so the user's intent wins.
- **`MqttManager.publishNfcScan` always emits the four binding keys**, including the previously-absent `chip_manufacturer` / `cin_brand` / `nfc_ic_manufacturer` for NFC scans (the first two as `JSON.NULL`, the third as the decoded ISO/IEC 7816-6 manufacturer when available).

### Build
- **Version bump**: `2.0.2` → `2.0.4` (versionCode 58 → 60). Version `2.0.3` was used as an in-session marker and never released; the version line jumps from 2.0.2 directly to 2.0.4.

## [2.0.2] - 2026-05-21

### Added
- **RAIN CIN (Company Identification Number) decoder + ISO/IEC 7816-6 NFC IC manufacturer registry.** New `com.meerv.qdat.utils.RainCinIdentifier` decodes the PC-toggle + AFI (0xAE / 0xBC) signaled CIN from the leading EPC bytes (VLQ format, MSB = continuation flag, max 4 bytes / 28-bit CIN). Bundled snapshot of the RAIN brand-owner registry as `app/src/main/assets/rain_cin_registry.json` is preloaded in `Application.onCreate`. New `com.meerv.qdat.utils.NfcChipIdentifier` decodes the ISO/IEC 7816-6 manufacturer byte from NFC UIDs (position depends on the air interface — see `manufacturerFromIso14443aUid` vs `manufacturerFromIso15693Uid`). Both surface as `cin_brand` and `nfc_ic_manufacturer` in MQTT scan payloads and as additional chip-type filter entries in the spinner.
- **NFC-A (ISO/IEC 14443 Type A) ingestion** alongside the existing NFC-V (ISO/IEC 15693) path. `MainActivity.onNfcTagTapped` now identifies the air interface and `MqttManager.publishNfcScan` dispatches to the correct `manufacturerFromIso14443aUid` (the manufacturer byte is at a different offset than NFC-V). Without this dispatch the NTAG / Mifare scans were silently missing the `nfc_ic_manufacturer` field on MQTT.
- **qdat.io ingest stress payloads.** `dist/qdat-io-stress-payloads.json` (331 payloads) — one synthetic SCAN per recognized chip / brand / NFC IC plus documented fallback cases. Generated by `scripts/generate_qdat_io_tag_types.py`. Backend can replay every payload and assert that none falls back to "unknown" downstream. `dist/qdat-io-tag-types.json` exposes the full tag-type reference (UHF chips by TID prefix, RAIN CIN registry, NFC IC manufacturers, OPUS states, OPUS alarms) plus a `mqtt_payload_reference` documenting the on-wire shape of every emitted key.
- **Per-column sort UX in TABLE view.** Long-press on any column header opens a dialog with a width SeekBar + ASC/DESC radio buttons. Sort direction persists per-column in `tag_table_col_sort_<columnId>`. Short tap on header re-applies the column's saved direction and starts a 10 s auto-revert timer so sorts don't stick after the user moves on.
- **Auto-stop inventory timer.** Configurable per-session timeout (default 10 min, range 1–30 min, "Off" available). Stored in `AppSettings.auto_stop_minutes`. Implemented by `AutoStopManager` in `MainActivity`: starts on inventory start, cancels on Stop / disconnect, fires a snackbar prompt then auto-stops when the timer expires. Protects the reader from overheating during unattended sessions.
- **Read Logger Configuration tab — Pause / Refresh Auto toggle** plus live countdown overlay. Tapping the Logger Data button toggles auto-refresh on/off; the icon swaps between pause and play glyphs, the button label shows `Xs HH:MM:SS [icon]` with the seconds-until-next-sample countdown derived from `OpusLoggerManager.parseNextLogAddress(tid)` + the configured log interval. Countdown ticks from TID so it survives pause without resyncing from the SDK. Auto-refresh trigger no longer flips the pause flag (separation of "schedule" vs "user intent").
- **Read Logger Configuration footer button** becomes "Exit to Main Screen" once the user is mid-read, so closing the dialog is a single-tap action instead of nudging the back gesture.
- **Read Logger chart enhancements**: per-segment alarm coloring (segments above/below the configured high/low thresholds turn red), an alarms summary card showing `First: yyyy-MM-dd HH:mm:ss — Last: yyyy-MM-dd HH:mm:ss` for the FIRST and LAST violation samples, a Local Time / UTC checkbox that propagates to both the chart axis and the alarm card, chart Y-axis tick gridlines on a 0.5 °C step, pinch-zoom `from:` / `to:` range display that tracks the gesture in real time.
- **Read Logger table auto-scroll** to the latest sample as new rows arrive; opts out the moment the user manually scrolls upward (`recyclerView.addOnScrollListener`).
- **Read Logger pre-flight**: before opening the Read Logger dialog, `MainActivity` does a quick `readWait` against TID. If it times out 3× the dialog never opens and a "Too far from tag" toast appears instead — saves the user from the 30-second futile-retry storm that used to wedge the SDK.
- **CSV export feedback.** Visible snackbar confirming sample count + path after a Read Logger CSV export. EPC + TID printed inline as labels on the export header.
- **Reader-unresponsive prompt.** When the SDK lock stays wedged after `forceReleaseSdkLock` retries (all stopAccess + Inventory.stop calls throw across both passes), `RfidInventoryManager.onReaderUnresponsive` fires; `MainActivity` surfaces a dialog telling the user to restart the app since further recovery from inside the process isn't reliable.

### Changed
- **NFC manufacturer byte picked by air interface in MQTT scan payload.** Pre-fix: NTAG / Mifare scans were always missing `nfc_ic_manufacturer` because the byte position depends on whether the UID is ISO 14443-3 Type A (NFC-A) or ISO 15693 (NFC-V), and we were only checking the latter. Post-fix: `MqttManager.publishNfcScan` dispatches by `effectiveTagType`.
- **Status-tab Refresh button removed** from the Read Logger dialog. The auto-refresh toggle on the Logger Data button supersedes it, and dual refresh paths were producing double-reads.
- **`Status-tab Next Sample` always displays as `HH:MM:SS`**, never localized text. The countdown is a duration, not a clock time — using `DateFormat` was producing garbled output in some locales.
- **EPC-strip is now PC-word-aware.** `TagInfo.normalizeEpc(epc, pcWord)` reads the PC's EPC-length bits (11-15) and trims the EPC to exactly the declared length before stripping trailing `0000` / `FFFF` padding words. Replaces the legacy "strip all trailing zeros" path that occasionally chopped valid hex.

### Fixed
- **Logger-data table First/Last arrows.** Arrows now correctly jump to the first and last row of the visible filtered data (was scrolling past the end / staying anchored at row 0 in some configurations).
- **Marker popup follows Local Time / UTC toggle.** The chart marker bubble was always showing UTC; now matches the chart axis setting.
- **Alarm First/Last sentence case** and single-line formatting.
- **`Count` column default widths** tweaked from 40 dp → 44 dp → 49 dp to avoid clipping multi-digit counts at default zoom.

### Build
- **Version bump**: `1.9.9.5` → `2.0` (versionCode 55 → 56), then incremental cleanup ramps to `2.0.2` (versionCode 58). Two intermediate bumps to `2.0.1` were reverted and re-applied in the same session before settling on `2.0.2`.

## [2.0] - 2026-05-21

### Added
- **Live next-sample countdown inside Read Logger dialog Status tab.** New 3-row info panel under the Logger Data chart shows the next-sample timestamp, the configured log interval, and a live `HH:MM:SS` countdown to the next sample. Countdown derived from `OpusLoggerManager.parseNextLogAddress(tid)` × configured interval + ARM-time epoch.
- **Next-sample countdown card next to Battery** on the Status tab — same data, 50/50 row layout with the Battery card.
- **"Next sample in Xs" overlay on the Status-tab Refresh button** so the user knows when the next sample will actually appear before they tap.
- **Read Logger pre-flight** — bails early when the tag is out of range. Quick `readWait` against TID with 3 attempts; if all time out, the dialog never opens and a "Too far from tag" toast appears.

### Changed
- **Layout cleanup pass** across the Read Logger dialog: alignment, spacing, label-vs-value column widths on the Configuration tab.
- **Refresh button now ticks the countdown to the next-future tick** when the current sample is overdue (was sometimes showing a negative countdown if the device clock jittered past the next-sample epoch).

### Fixed
- **Post-operation leaks around Read Logger / Locate Tag.** Multiple cleanup paths fixed:
  - `cancelReadLoggerPrepTask` now calls `reader.Actions.TagAccess.stopAccess()` before nulling out its references, instead of leaving the SDK in an access state that would block the next inventory restart.
  - In-flight Read Logger retries are cancelled when the user closes the dialog, instead of completing in the background and writing into a dead UI.
  - Status / Config refresh paths are now guarded during an active logger read so they don't race the in-progress access cycle.
- **Auto-refresh trigger fires on `nextLogAddress` advance** (was firing only on TID-prefix change, missing samples between reads).

### Build
- **`.gitignore`** ignores `.DS_Store` and `gradle-daemon-jvm.properties`.
- **Version bump**: `1.9.9.5` → `2.0` (versionCode 55 → 56).

## [1.9.9.5] - 2026-05-20

### Added
- **Civic-address reverse geocode in GPS Settings.** New `tv_gps_address` italic line in `activity_gps_settings.xml` directly below the live/override coordinate readout. `GpsSettingsActivity.scheduleReverseGeocode(lat, lon)` hits Photon's `/reverse?lat=…&lon=…&limit=1` endpoint, reuses the existing OkHttp client + `parsePhoton` JSON parser already wired for forward geocoding, and renders `"<primary>, <secondary>"` (e.g. *"Place du Canada, Montreal, QC, Canada"*). Called from both `updateStatusText(loc)` (live fixes) and `updateOverrideMarker()` (override pins). Coalesced by a 0.0001° gate so 1 m GPS jitter doesn't spam the API; in-flight calls are cancelled on a new query.
- **`gps_settings_no_provider` string** — "Device has no GPS / network location provider available." Shown by `GpsSettingsActivity.updateStatusText(null)` when the device lacks both a GPS chip and an active network-location provider, so the user no longer sits forever staring at "Waiting for first GPS fix…".
- **`gps_override_active` string** — "GPS: Override". Displayed by `MainActivity.updateGpsStatus` when the green-icon state is sourced from a manual override rather than a live fix.

### Changed
- **GPS icon tap behavior on the main screen.** The icon's old click handler called `showGpsInfo()` which surfaced a snackbar / opened the map. Replaced with `MainActivity.handleGpsIconTap()`:
  - **Red icon (GPS disabled)** → opens `GpsSettingsActivity` directly so the user can re-enable.
  - **Green icon (GPS enabled)** → flips `AppSettings.gps_enabled=false` and calls `GpsManager.stopLocationUpdates()`. **Override coordinates and live/override mode choice in `GpsSettings` are NOT touched** — they persist for the next re-enable. The map shortcut is gone.
- **`updateGpsStatus` override short-circuit.** When `gps_enabled=true` AND `gps_override_enabled=true` AND the saved override lat/lon are non-zero, the indicator goes green instantly without waiting for a live fix, listener state, or location permissions. Returns "GPS: Override" as the status text. Restores green-icon UX the moment the user re-enables GPS on a device pinned to a manual point.
- **`updateGpsStatus` no longer leaves the icon at the XML default green.** When `gpsManager == null` (initialization race) the icon, dot, and label are now explicitly tinted `error_color` red with "GPS disabled" text. Previously the early `return` left whatever color the layout's `app:tint="@color/success_color"` had drawn.
- **"Searching for GPS signal…" gated on device capability.** `MainActivity.updateGpsStatus` no longer shows the yellow "Searching" state on devices that can never produce a fix. `deviceCanProduceGpsFix()` checks `PackageManager.FEATURE_LOCATION`, `FEATURE_LOCATION_GPS`, and `FEATURE_LOCATION_NETWORK`; on a no-feature-no-provider device the path falls through to "GPS inactive" (grey) instead of dangling yellow.
- **GpsSettingsActivity "Waiting for first fix…" gated on capability.** Same `hasSystemFeature` + `isGpsEnabled()` check applied to `updateStatusText(null)`. The hard-coded bootstrap `setText(R.string.gps_settings_no_fix_yet)` at the end of `onCreate` was bypassing the gate — now routed through `updateStatusText(null)` so the capability check fires uniformly.
- **`onResume` restarts location updates** when `gps_enabled` pref is true but `GpsManager.isListening()` is false. Handles the green→tap-off → re-enable-in-Settings round-trip where nothing was previously restarting the listener.
- **NFC UID byte order on MQTT.** `MainActivity.onNfcTagTapped` now passes `info.displayUid` (MSB-first, printed-on-card form) to `MqttManager.publishNfcScan(...)` instead of the raw wire-order `uidHex`. The MQTT outbox dedup key and `tag_registered` ack handler now operate on the same string the user sees in the list. Display, history DB, and MQTT payload finally agree.

### Fixed
- **Inner-view click hijack.** The old `gpsIcon.setOnClickListener(v -> showGpsInfo())` was firing before any container-level handler because the inner `ImageView` consumed the touch. The new icon handler is now on `gpsIcon` directly with the toggle/jump logic.
- **GPS Settings activity now triggers civic-address resolution on override too.** `updateOverrideMarker()` was setting the coordinate text + map pin but never resolving the human-readable address. Now calls `scheduleReverseGeocode(lat, lon)` after the marker is placed.

### Build
- **Version bump**: `1.9.9.4` → `1.9.9.5` (versionCode 54 → 55).

## [1.9.9.4] - 2026-05-20

### Added
- **Dedicated `GpsSettingsActivity`** (`com.meerv.qdat.GpsSettingsActivity`, ~527 lines, registered in `AndroidManifest.xml`, opened from a new **GPS Settings** entry in the toolbar overflow menu). The screen exposes:
  - **Enable GPS** checkbox — same `AppSettings/gps_enabled` pref previously hosted on the General Settings fragment (pref key unchanged, no migration needed).
  - **OSMDroid live map** with two markers: the live FusedLocationProvider fix (green) and the override pin (red). Tile provider configured with an explicit User-Agent and an app-private tile cache.
  - **Coordinate source** radio group — *Live GPS* vs *Override to a fixed point*. Long-press on the map drops / moves the override pin; pin position persists across reboots in `gps_override_lat` / `gps_override_lon` (stored as `Double.doubleToLongBits` longs) gated by `gps_override_enabled`.
  - **Manual lat/lon entry** with decimal-degree validation (rejects out-of-range values with a localized "use 45.5017" hint).
  - **Civic address lookup** via Photon (komoot.io) with a 350 ms debounce, 8 s timeout, in-flight call cancellation, and a `RecyclerView` suggestion list. Each tap centres the map and sets the override.
  - **Center on live location** + **Clear override** action buttons.
- **GPS coordinates now travel with NFC scans.** `MainActivity.onNfcTagTapped` snapshots the current `GpsManager.getLastLocation()` onto the `TagInfo` (lat/lon/alt/timestamp/accuracy) before persisting to `tag_history` and threads them through to `MqttManager.publishNfcScan(uidHex, url, tagTypeName, lat, lon, alt)` (new overload — the no-GPS form remains for callers that don't have a fix).
- **GPS override in MQTT payloads.** `MqttManager` reads `gps_override_enabled` / `gps_override_lat` / `gps_override_lon` from `AppSettings`; when set, the override replaces the live fix in the `gps` sub-object of both UHF (`createTagDataJson`) and NFC (`publishNfcScan`) payloads, and an explicit `"override": true` field is emitted so downstream consumers can distinguish forced points from real fixes.
- **`GpsManager` observer pattern.** New `OnLocationUpdateListener` interface plus `addLocationListener` / `removeLocationListener` (backed by `CopyOnWriteArrayList`). Each `onLocationChanged` callback now also fans out a `GpsLocation` snapshot to all registered observers, with per-observer exception isolation. Lets the GPS Settings screen redraw the live marker without polling.
- **NFC "Open URL" button on tag rows.** `item_tag.xml` gains a green Material button (with `ic_globe` leading icon) that appears only on NFC rows carrying an NDEF URL. The same action is added to the long-press popup menu on detailed view and to a dedicated card-view popup. `TagsAdapter.launchUrl(String)` fires `ACTION_VIEW` with `FLAG_ACTIVITY_NEW_TASK` and swallows malformed-URL exceptions.
- **`TagInfo.displayUid`** — optional rendering override for the UID field. For NfcV (ISO 15693), `Tag.getId()` returns wire/LSB-first bytes; `MainActivity.reverseHexBytes` produces the printed-on-card MSB-first form and stores it here. The wire-order `epc` field stays unchanged so MQTT and DB keys remain stable.
- **`TagsAdapter.findEpcPosition(String)`** — locates the row index for a given EPC/UID in the currently-displayed list (honours filters and hybrid-sort pinning). Used by the NFC tap path to scroll precisely to the just-updated row.
- **`ic_globe` vector drawable**, `item_gps_suggestion.xml` layout for the address autocomplete row, `activity_gps_settings.xml` (274 lines, MaterialToolbar + NestedScrollView + cards).
- **23 new string resources** covering the GPS settings screen (mode labels, marker labels, address attribution, validation hints), the **Open URL** button, and the **NFC tag URL** dialog title.

### Changed
- **GPS toggle moved off `AppGeneralSettingsFragment`.** `fragment_app_general_settings.xml` drops the `cb_gps_enabled` checkbox and the "GPS Settings" subheading; `AppGeneralSettingsFragment.java` drops the field reference plus all load/save/reset wiring. The underlying `AppSettings/gps_enabled` pref key is untouched, so existing values and `MqttManager.isGpsEnabled()` behave identically.
- **GPS status indicator always visible.** When GPS is disabled in settings, `MainActivity.updateGpsStatus` now keeps the container visible and tints the icon, dot, and label `error_color` red (instead of hiding the row entirely). Users can tell at a glance that GPS is off.
- **NFC URL surfacing reworked.** Removed the always-on `Toast.makeText(...nfc_url_toast...).LENGTH_LONG` that fired on every NFC tap with a URL. The URL is now reachable via the row's **Open URL** button / popup menu instead. Tag row autoscroll on NFC tap switched from "row 0 in card view, last row in table view" to `findEpcPosition(uid)` + `scrollToPositionWithOffset(pos, 0)`, so the just-tapped row lands at the top of the viewport regardless of filters or sort.
- **`NfcController.uriFromRecord`** now prefers Android's built-in `NdefRecord.toUri()` (handles Smart Poster wrappers, `RTD_URI`, `TNF_ABSOLUTE_URI`, and external types correctly) and falls back to the previous manual decode only when `toUri()` returns null/empty or throws.
- **Quieter MQTT notifications.** The `backendVersionListener` snackbar (`"Backend v…"`, previously triggered on every `hello_response`) is now an `Log.i` only — the toast fired on every reconnect and was noise. The `GET_VERSION` command-acknowledgement snackbar is also suppressed (`message = null`); other MQTT commands still surface a snackbar.

### Build
- **Version bump**: `1.9.9.3` → `1.9.9.4` (versionCode 53 → 54).

## [1.9.9.3] - 2026-05-19

### Added
- NFC ingestion path (NfcController + NFC_V tag rows in TagsAdapter).
- Unified `tag_history` Room database covering both UHF and NFC detections (discriminated by `tag_type`).
- Hybrid sort with top-3 recency pinning.
- MQTT outbox queue that survives process death and replays on reconnect.

### Changed
- Broad UX overhaul across the tag list and detail views (see commit `5fc23f5` for full diff).

### Build
- Version bump: `1.9.9.2` → `1.9.9.3` (versionCode 52 → 53).

## [1.9.9] - 2026-05-12

### Added
- **PC-word-aware EPC normalization.** New static `TagInfo.normalizeEpc(String epc, int pcWord)` trims the EPC to the bit length declared by the PC word (bits 11-15 = EPC length in 16-bit words), preserving valid 96/112/128-bit boundaries without truncating real data. `getDisplayEpc()` now uses this overload, picking the most accurate PC source (`pc` ↔ `pcFromGetPC`).
- **Trailing-padding strip extended to `FFFF`.** `TagInfo.normalizeEpc(String)` iteratively removes trailing whole-word `0000` and `FFFF` (case-insensitive) padding before the legacy zero-strip and even-length padding. Example: `5201F250300009200000FFFF` → `5201F25030000920`. Applies everywhere (display, ARM/Read SDK calls, MQTT JSON, history DB keys, map dedup).
- **MQTT alarms on Read Logger.** `publishLoggerDataWithSamples` and `publishLoggerComplete` now emit an `alarms` JSON array on batch 1 (alongside `config`) and on the completion message. Sources are the full OPUS alarm set when `opusInfo` is available (PC bits 0-2 + XPC bit 11 + TID word 0x10 bits 0-3 → up to 6 distinct alarms), or PC-only via `PcDecoded` as fallback.
- **Inventory MQTT alarms now include the OPUS-only set.** Previously the `alarms` array in `createTagDataJson` carried only the 3 PC-word alarms (`TAMPER`, `LOW_BATTERY`, `TEMPERATURE`). It now merges in `HIGH_TEMPERATURE`, `LOW_TEMPERATURE`, and `INITIAL_LOW_BATTERY` from `opusInfo.getAlarms()`, deduplicated, while remaining backward-compatible (the original 3 names still appear unchanged when present).
- **Shared `MqttManager.buildAlarmsSet` / `buildAlarmsJson` helpers** (pure logic + JSON wrapper) so all three sites — inventory, logger batch 1, logger complete — go through one deduplicated path.
- **Unit tests** — `TagInfoEpcTest` (18 cases) and `MqttManagerAlarmsTest` (7 cases) covering PC-aware EPC normalization, `0000`/`FFFF` trailing-word strip, and PC-only alarm extraction.

### Changed
- **`RfidDataFormatter.cleanEpc(String, int pcWord)`** signature now takes the PC word and delegates trimming to `TagInfo.normalizeEpc`. The old behavior force-padded/truncated every EPC to exactly 24 chars (96 bits) — actively destroying data for 112-bit (28-char) and 128-bit (32-char) tags. The single caller (`RfidInventoryManager.processTag`) updated to pass `tag.getPC()`.
- **`MqttManager.publishLoggerDataWithSamples` / `publishLoggerComplete` / `publishLoggerData`** signatures take `int pcWord` (and `OpusParser.Information opusInfo` for the two active methods) so the EPC strip and alarm decoding run at the JSON boundary using the authoritative PC. `OpusReadDialogHelper` snapshots `tagInfo.opusInfo` and threads it through.
- **`MqttManager.createTagDataJson` (inventory)** — `data.put("epc", ...)` now normalizes through the PC-aware path before publishing.
- **`MqttCommandExecutor.createTagJson`** — switched to `tag.getDisplayEpc()` so scan responses to backend commands are also stripped.
- **`MqttResponsePublisher`** — applies legacy zero/FFFF strip on response payloads where no PC word is available, ensuring backend command responses stay clean.
- **Reader connection-type labels** — `"USB (Integrated)"` → `"USB"` and `"Serial (Integrated)"` → `"Serial"` in `ReaderSelectionAdapter.getConnectionType` and the parallel branch in `MainActivity`. Bluetooth / Wi-Fi / Ethernet rows unchanged.

### Build
- **Version bump**: `1.9.8` → `1.9.9` (versionCode 49 → 50).

## [1.9.8] - 2026-05-04

### Added
- **Locate Tag: no-signal timeout** — when no read for the targeted EPC arrives within 1 s, all live signal values reset to 0 / "--": RSSI raw + smoothed, distance %, distance meters, signal quality, phase, antenna ID, direction indicator. The smoothed-RSSI EMA buffer is also reset (`LocateMetrics.reset()`) so the next reading restarts fresh instead of blending stale data.
- **Locate Tag: continuous "no signal" chart points** — the no-signal handler reschedules itself every 1 s while the timeout condition persists, plotting a fresh point on the RSSI/percentage chart each second (RSSI = chart floor -90 dBm, percentage = 0 %). The user sees a flat baseline grow as long as the tag stays out of range; the loop stops as soon as a real read arrives.

### Changed
- **Locate Tag layout** — Signal Quality, RSSI Raw and RSSI Smoothed merged onto a single 3-column row (was 2 rows). Removed the separator divider; ProgressBar shrunk 80 dp → 60 dp; RSSI value text 16 sp → 14 sp; column labels 11 sp → 10 sp. `tv_distance_meters` and `tv_distance_indicator` kept as 0×0 hidden views to preserve Java references in `TagLocateHelper`.
- **Backend domain migration: `qdat.dev` → `qdat.io`** across `QDatApiClient`, `LicenseApi`, `LicenseManager`, `LicenseRepository`, `PredictApiSettingsFragment`, `network_security_config.xml`, and the `api_url_hint` strings in all 8 locales. Cleartext-traffic exemption updated to the new domains.
- **Feedback email** updated `support@qdat.io` → `support@meerv.com` in `values/strings.xml`.

### Fixed
- **Locate Tag chart points landing at the wrong X position** — the trim-and-reindex logic that ran when `rssiHistory` exceeded `MAX_HISTORY_SIZE` reassigned the list reference (`rssiHistory = reindexedRssi`) and computed `nextX = rssiHistory.size()`, which could produce non-monotonic X values (e.g. a new entry landing at position 12 while the dataset already had 30 entries, then jumping to 31). Replaced with a strictly-monotonic `chartXCounter` that grows for the entire locate session and never reindexes — `setVisibleXRangeMaximum(MAX_HISTORY_SIZE)` + `moveViewToX(nextX)` keep the visible window pinned to the latest sample. Both `rssiHistory` and `percentageHistory` are now cleared together on `startLocating` / `stopLocating`.

### Build
- **gradle-play-publisher 3.12.1** plugin added to the root `build.gradle` classpath for AAB / listing / screenshot uploads to Google Play.
- **Version bump**: `1.9.7` → `1.9.8` (versionCode 48 → 49).

## [1.9.7] - 2026-04-30

### Added
- **Pause / Resume on Read Logger**: the "Refresh Reading" button switches to **Pause** while the logger read is in flight. Tapping pauses the read cleanly (preserves `lastSuccessfulBlock`, chart entries, MQTT batch state); the button then becomes **Resume** and continues from the last successful block. Strings added in 8 locales (`btn_pause_logger_data`, `btn_resume_logger_data`).
- **Auto-retry on Read Logger error**: up to 3 attempts with 600 ms back-off between them. Each retry resumes from `progress.lastSuccessfulBlock`, never restarts from zero. After exhausting the auto-retries the button surfaces **Resume** so the user can continue manually.
- **Status text preserves sample count on error/pause**: `Error during reading (Y/Z samples)` and `Paused (Y/Z samples)` (8 locales) replace the bare `Error during reading` so progress context is kept.
- **Shared progress bar / status row across all Read Logger tabs**: the progress section was lifted out of `tab_logger_data` into a sibling above `tab_content_container`, so Status / Configuration / Chart all show the live read progress.
- **`OpusTagDecoder.Information.hasInvalidSampleCount()`** flag set when `nextSample > configuredSamples` (corrupt TID). `OpusReadDialogHelper.refreshTagData` detects this and re-reads the TID once to recover trustworthy logger figures.
- **`RfidController.forceResetToIdle()`** public recovery hook — clears `currentState`, `currentCallback`, `currentOperation`, `retryCount`, and the operation queue. Last-resort break-out when an SDK stop times out.
- **`BuildConfig.APP_NAME`** field (`"qdatdroid"`) added to `defaultConfig`; the MQTT `GET_VERSION` response uses it instead of a hardcoded literal.

### Changed
- **Rebrand cleanup: removed all remaining "cooldat" references.** API URLs (`model.cooldat.dev` → `model.qdat.dev`, `license.cooldat.dev` → `license.qdat.dev`), MQTT default topics (`cooldat/scan` → `qdat/scan`, `cooldat/logger` → `qdat/logger`), MQTT client ID prefix (`cooldat_` → `qdat_`), Room DB filename (`cooldat_history.db` → `qdat_history.db`), license API product / User-Agent, network security config domains, and UI hint defaults across all 9 locales now use `qdat`.
- **MQTT backend protocol contract**: removed `USERAPP_COOLDAT` compat shim in `MqttCommandParser`. Only `userapp="qdat"` envelope commands are accepted; legacy `userapp="cooldat"` commands are silently ignored.
- **License API payload**: hardcoded `version: "1.0.4"` and User-Agent `CoolDat-Android/1.0.4` replaced with `BuildConfig.VERSION_NAME`.
- **`RfidInventoryManager.startInventory`** issues a defensive `forceReleaseSdkLock` on entry (two `stopAccess()` + `Inventory.stop()` passes + `purgeTags()` with logging) before reconfiguring filters, and the catch block logs `OperationFailureException` vendor message + status instead of the always-`null` `e.getMessage()`.
- **`MainActivity.restartInventoryAfterDialog`** calls `TagAccess.stopAccess()` before `Inventory.stop()` (and again between retry attempts) — a previous `readWait`/`readEvent`/`writeWait` left the reader in TagAccess mode, blocking subsequent inventory starts.
- **Periodic refresh interval in Read Logger** bumped 1500 ms → 4000 ms.

### Fixed
- **Logger read sample dedup** — `progress.chartEntries` was bloating to ~16-67× the configured sample count because the previous `List.contains(entry)` dedup relied on `Object.equals` (identity), which never matched against fresh `Entry` instances. Each `onProgress` callback re-appended its cumulative chartData. Replaced with a `HashSet<Integer>` keyed on the X coordinate (sample number), restoring linear-time merge and capping `chartEntries.size()` at `totalSamples`. Chart now renders the correct count.
- **ANR / Davey 900 ms frames during a logger read** — `onProgressUpdate` rebuilt the result `TextView` (full text), the chart, the statistics panel, and the temperature `RecyclerView` adapter on every block (32 times for 4096 samples). Added a 350 ms throttle for the heavy redraws (chart + adapter + result text + MQTT batch); progress bar and status text stay instant. Last batch always paints. Throttle is reset on each new run / resume so the first tick paints immediately.
- **10-second freeze (Davey 10 703 ms) at mid-read** — even with the 350 ms throttle, an unthrottled tick combined `temperatureSampleAdapter.setSamples()` (clear + addAll + sort + `notifyDataSetChanged` for ~2000 items) with the `mqttManager.publishLoggerDataWithSamples(...)` call (60 kB JSON serialization) on the main thread. Now:
  - The RecyclerView adapter has its own longer throttle (`ADAPTER_REBUILD_THROTTLE_MS = 2 s`) since the table is on a separate tab — last batch always rebuilds.
  - MQTT batch publish is fully offloaded to a single-thread background executor (`mqttBatchExecutor`); main thread snapshots the data + advances `lastMqttSentSampleCount`, the worker builds the `SampleData` list, serializes JSON, and calls Paho's `publish()`.
- **"Error during reading (4096/4096 samples)" after a successful retry** — when an attempt failed mid-read, `progress.fullData` carried the appended `Error reading logger data: TIME OUT` text. Even though the retry then read the remaining blocks successfully, `result.contains("Error")` stayed `true` and the success path was skipped. `onPostExecute` now treats `chartEntries.size() >= totalSamples` as the canonical success signal regardless of leftover error text, and `stripLastReadingErrorAppendage(progress.fullData)` removes the localized "Error reading…" line before retry and on success so the result view doesn't flash stale errors.
- **`Inventory stop timeout` no longer leaves the SDK locked** — when `safeStopInventoryForRead` exceeds its 3 s wait, the controller used to stay stuck in BUSY_STOPPING and the next `readEvent` failed with `LOCK_ACQUIRE_FAILURE` for minutes. Now it calls `RfidController.forceResetToIdle()` + `forceReleaseSdkLock()` immediately on timeout, clearing both the controller state and the SDK lock.
- **`LOCK_ACQUIRE_FAILURE` recovery on inventory start (readEvent path)** — wraps `reader.Actions.TagAccess.readEvent` in a 4-attempt loop with 0 / 1500 / 3000 / 5000 ms back-off. Between attempts, `forceReleaseSdkLock` issues two passes of `stopAccess()` + `Inventory.stop()` with explicit logging, then `purgeTags()`. Other `OperationFailureException` types are rethrown without retry.
- **Periodic refresh in Read Logger no longer stacks on itself** — new `isPeriodicRefreshInFlight` guard skips the scheduled tick while a previous refresh's `readWait`/UI cycle is still running. Without this, refreshes piled up faster than the SDK could drain them, producing the cascade of `INVENTORY_STOP_EVENT` + `Radio Response Timeout` every 3 s and 99 % RenderThread saturation.

### Migration notes
- **Existing installs orphan their `cooldat_history.db`**: the new DB `qdat_history.db` starts empty. Tag detection history and logger readings prior to this version are not migrated.
- **MQTT broker subscriptions must be re-keyed** to `qdat/*` topics or backend re-publishes accordingly.
- **Backend prerequisite**: `model.qdat.dev` and `license.qdat.dev` must resolve with valid TLS certs, and the backend must publish `set_reqToUserapp` envelopes with `userapp="qdat"`.
- **Public user docs require regeneration** via `user-doc-generator/` to refresh the generated HTML / JSON outputs under `user-docs/`, `user-docs-test/`, and `user-docs-json/data/`.

## [1.9.6] - 2026-04-20

### Added
- **Configurable trigger behavior (HOLD / TOGGLE)** — new `TriggerController` (`com.meerv.qdat.rfid.TriggerController`) that applies a user-chosen mode to the Zebra reader's hardware trigger:
  - **HOLD** — inventory runs while the trigger is held down, stops on release.
  - **TOGGLE** (default) — first press starts inventory, next press stops it.
  - SDK 2.0.5.238 cannot distinguish the upper/lower RFD40 triggers (only `HANDHELD_TRIGGER_PRESSED` / `HANDHELD_TRIGGER_RELEASED` events available), so the mode applies to any trigger press.
  - Owner-tracking prevents TOGGLE from stopping an inventory started elsewhere (e.g. via the UI button).
- **RFID Settings → General → "Trigger behavior"** spinner (EN/FR) to switch HOLD ↔ TOGGLE, persisted in `RFIDGeneralSettings:trigger_mode`. Change takes effect immediately via `RfidService.setTriggerMode()` — no reconnect needed.
- **Log History bottom action bar** — Export and Clear buttons are now fixed at the bottom of the screen (matching the Settings pattern) instead of the toolbar overflow menu. Clear button uses a red background with a trash icon.

### Changed
- **Removed duplicate trigger handling** in `RfidService.eventStatusNotify` and `MainActivity.eventStatusNotify`. All trigger press/release events are now routed through `TriggerController`, which delegates to `RfidInventoryManager` so the `RfidController` state machine stays authoritative (avoids `RFID_OPERATION_IN_PROGRESS` races between the UI button and the physical trigger).
- **"Clear all history" button relabelled to "Clear"** (EN) / **"Effacer"** (FR) with a new `ic_delete` trash icon. Confirmation dialog content unchanged.
- Log History toolbar no longer shows the overflow (`…`) icon — menu file `res/menu/menu_tag_history.xml` removed.

## [1.9.5] - 2026-04-20

### Added
- **Log History feature**: persistent history of detected tags and logger readings, reachable via the toolbar overflow menu (`…` → **Log History**).
  - **Room database** (`cooldat_history.db`): `tag_history` (one row per unique EPC — first/last seen, detection count, latest sensor values, GPS) and `logger_reading` (one row per Read Logger session, full temperature samples stored as JSON).
  - **TagHistoryActivity**: list of all detected tags, default sort by last seen.
  - **TagHistoryDetailActivity**: per-tag view — last known sensor values, detection stats, list of logger sessions.
  - **TagHistorySessionActivity**: per-session chart + sample table. Parsing runs on the IO executor with a centred `ProgressBar` spinner while loading to avoid ANRs on 4096-sample reads.
  - Integration: `RfidService.inventoryCallback` calls `HistoryRepository.recordTagDetection()` on every tag event; `OpusReadDialogHelper` persists complete reads (on success) and partial reads (on dialog dismiss with `lastSuccessfulBlock > 0`).
- **Filter bar aligned with the inventory screen** (identical layout and semantics):
  - Search field restricted to **hex characters only** (`0-9`, `A-F`) via `android:digits` + `InputFilter.AllCaps`. Same restriction applied to the inventory EPC filter.
  - **Tag Type** dropdown built dynamically via `TagTypeIdentifier` (discovers Axzon OPUS, Magnus, Impinj, etc. from TID prefixes) — starts with *All Types*.
  - **Status** dropdown reuses `@array/status_filter_options` (All / None / Sleep / Standby / Armed / Started / Logging / Finished), matched against `TagHistoryEntity.lastState`.
  - **Alarm** dropdown reuses `@array/alarm_filter_options` (All / With Alarm / No Alarm), decoded from the stored PC word via `PcDecoded.hasAlarms()`.
- **Export menu** in the Log History toolbar with a chooser modal and **Storage Access Framework** picker (user selects CSV/XLSX/JSON location — internal storage, SD card, Drive, Dropbox, etc.):
  - `HistoryExporter.exportCsvToUri` — multi-section CSV (Tag Summary + Logger Readings one row per sample).
  - `HistoryExporter.exportXlsxToUri` — single `.xlsx` with 3 sheets (Tags, Sessions, Samples) via **fastexcel 0.19.0** (~300 KB, streaming writer, Android-friendly).
  - `HistoryExporter.exportJsonToUri` — structured JSON with `exported_at`, `tags[]`, `logger_readings[]` (samples embedded).
  - **Send to MQTT** option in the export dialog is only listed when `FEATURE_MQTT` is enabled AND `MqttManager.isEnabled()` is true (no greyed-out entry).
- **Clear All History** action available in:
  - The Log History toolbar overflow (`…` → Clear all history) — protected by confirmation dialog.
  - The Application Settings **History** tab — same confirmation dialog, same outcome.
- **Application Settings → History tab** (new tab position computed dynamically by flavor):
  - Database statistics: tags tracked, OPUS count, logger session count, total temperature samples, database size (MB), oldest entry date.
  - Retention configuration: spinner with presets (Forever / 7 / 15 / 30 days / 3 / 6 months / 1 year / Custom) + custom days input (1–3650). Stored in `AppSettings:history_retention_days` (0 = forever).
  - Only Clear All remains in Settings; export actions moved to the Log History toolbar.
- **HistoryCleanupWorker** — WorkManager periodic task (24 h) purges rows older than the configured retention. Enqueued in `Application.onCreate` with `ExistingPeriodicWorkPolicy.KEEP`.
- **Retry Reading button** — the OPUS Read Logger dialog's "Refresh Reading" button switches to **"Retry Reading"** (8 languages) when an error is shown, making it explicit that the action resumes the partial read instead of restarting.
- Toolbar theming on all three Log History activities: `ThemeOverlay.AppCompat.Dark.ActionBar` so the overflow icon and navigation back arrow render white on the primary-coloured toolbar, with a `ThemeOverlay.AppCompat.Light` popup theme keeping the dropdown readable.

### Fixed
- **TC22R (SERVICE_USB / integrated RFD40) Logger Data bulk read**: `readWait` with 128-word chunks was timing out on SERVICE_USB. Fix uses **`readWait` with 32-word chunks** on SERVICE_USB/SERVICE_SERIAL transports (confirmed stable after testing 64w/128w failures), keeping 128 words on BLUETOOTH and 16 words on RE_SERIAL.
  - New `ReaderCapabilityUtils.getBulkReadWordsPerRead()` returns the transport-specific chunk size.
  - `OpusLoggerManager.readCompleteLoggerData` uses this value, removes the 50 ms inter-block `Thread.sleep` (~6 s saved on 4096 samples), and adds detailed per-block logging.
- **Partial Logger Read resume** — new `onBlockComplete(int completedBlocks)` callback on `LoggerReadCallback` tracks `lastSuccessfulBlock` live. New `readCompleteLoggerData(..., int startBlock, ...)` overload lets Refresh Reading **continue from the last successful block** instead of restarting from zero; chart entries, exports, and MQTT state are preserved across the retry.
- **TagHistorySessionActivity ANR** — 4096-sample JSON parsing + `SimpleDateFormat × 4096` was running on the main thread, exceeding the 5 s input dispatcher timeout. All parsing now happens on `HistoryRepository.ioExecutor`; only `chart.setData` / `adapter.setSamples` run on the UI thread.
- **Clear All history crash / "reader reconnect"** perceived bug — `SnackbarHelper.positionAndShow()` cast LayoutParams to `FrameLayout.LayoutParams` but the RecyclerView is parented by a `CoordinatorLayout`, throwing `ClassCastException` and crashing the app (which the user saw as the RFID reader re-connecting after restart). All snackbar calls in `TagHistoryActivity` switched to the `(Activity, …)` overload, which resolves a compatible root view automatically.

### Changed
- Added **Room 2.6.1** (runtime + compiler) and **fastexcel 0.19.0** (XLSX writer) dependencies to `app/build.gradle`.
- New overflow menu entry `action_log_history` with `ic_history.xml` drawable.
- `ApplicationSettingsPagerAdapter` and `ApplicationSettingsActivity.setupTabs` rewritten to compute the History tab position dynamically based on enabled flavor features (Predict API / MQTT).
- Log History filter layout refactored to mirror the inventory filter bar exactly: same row structure, same spinner labels (`filter_label_type`/`status`/`alarm`), same reused resource arrays. The previous "Sort by" spinner was removed to match the inventory UI.

## [1.9.4] - 2026-04-07

### Fixed
- **OPUS LED Bit Layout (Critical Protocol Fix)**: TID word `0x1E` LED configuration now matches the Axzon Python/C# SDK reference implementation. Previous layout was incorrect on every field:
  - `enabled` moved from bit 2 to **bit 13** (mask `0x2000`).
  - `mode` moved from bit 3 to **bit 12** (mask `0x1000`).
  - `offTime` moved from bits 6–10 to **bits 5–9** (mask `0x03E0`).
  - `onTime` moved from bits 11–15 to **bits 0–4** (mask `0x001F`) and is now interpreted as **milliseconds (10–320, step 10)** instead of RTC cycles. JSON encoding: `(ms / 10) - 1`; decoding: `(raw + 1) * 10`.
  - **Removed `pullDownResistance`** entirely (enum, encode, decode, default JSON, reduced-config map). The field does not exist in the Axzon protocol.
- **ARM Dialog LED On Time Spinner**: Spinner now displays correct values (`10 ms`, `20 ms`, …, `320 ms`) instead of bogus `1 (~3 ms)` … `32 (~100 ms)` based on the wrong RTC-cycle assumption. Same fix applied to `ArmProfileAccordionAdapter` (Settings → ARM Profiles).
- **Read Tag Configuration Display**: LED on-time table row now shows `X ms` instead of `X RTC cycles (~Y ms)`.
- **Hardcoded Default `onTime` Values**: `OpusArmingSettingsFragment.createDefaultConfigJson()` and `OpusConfigurationManager.armOpusLogger()` doc now use the new ms units. `OpusArmingSettingsFragment` default `onTime` corrected from `16` (invalid as ms) to `50`.

### Changed
- **Package Rename**: Renamed Java namespace and Android `applicationId` from `com.pulrtechnologies.cooldat` to `com.meerv.qdat`. Flavors now publish as `com.meerv.qdat.{base,business,enterprise}`.
- **AGP**: Bumped Android Gradle Plugin from 9.0.0 to 9.0.1.
- **ARM Profile Storage Schema Bumped**: `ArmProfileManager.KEY_INITIALIZED` bumped from `initialized_v3` to `initialized_v4`. On first launch after upgrade, stored ARM profiles are reset to the new defaults (with valid `onTime: 50` ms). User-customized profiles will be lost — this is intentional because old `onTime` values stored as RTC cycles (1–32) are invalid under the new ms units (10–320, step 10) and would fail validation when ARMing.

### Localization
- Updated `dialog_arm_config_led_on_time_label`, `led_on_time_label`, `dialog_arm_config_led_on_time_hint`, `dialog_arm_config_led_on_time_default`, and `opus_read_config_led_on_time` strings in all 8 locales (en/de/es/fr/it/ja/ko/zh-rCN) to reflect the new milliseconds unit.

### Migration Notes
- Existing installations of the previous app id cannot be upgraded in-place; the new app id is treated as a separate application by Android. Users will need to install fresh and lose their local SharedPreferences (license, MQTT config, app settings).
- Internal `LocalBroadcastManager` action constants (e.g. `ACTION_RFID_CONNECTION_STATE`) were renamed in lockstep with the namespace; no external listeners are affected.
- ProGuard `-keep` rules updated for the new package paths so reflective Gson/license/RFID classes survive minification.
- Tags ARMed with previous app versions will have an incorrect LED configuration word in TID `0x1E`. Re-ARM affected tags after upgrading to 1.9.4 to apply the corrected layout.

## [1.9.3] - 2026-04-07

### Added
- **MQTT HELLO Handshake**: Sends HELLO message on connect; backend responds with `hello_response` containing backend version, displayed via snackbar
- **MQTT Backend Version Listener**: New `BackendVersionListener` interface in `MqttManager` for receiving backend version info
- **MQTT Heartbeat App Version**: HELLO/PONG payloads now include `app_version` field
- **MQTT Command Dialog Cleanup**: Open dialogs (read, arm, reset, locate, write, loading, progress, reader selection) are dismissed before executing remote MQTT commands (INVENTORY/STOP/READ/ARM/REBOOT/RESTART)
- **Read Logger Status Refresh**: USER, TID, and EPC banks are re-read before fetching logger data, ensuring up-to-date tag state and PC word; refreshed data is republished to MQTT
- **Dialog Helpers `dismissDialog()`**: Added public dismiss methods to `BaseOpusDialogHelper`, `TagLocateHelper`, and `TagMemoryWriteDialogHelper` for external dismissal

### Changed
- **License Verification Disabled**: Background license verification work is no longer scheduled; previously scheduled work is cancelled on app start

## [1.9.2] - 2026-03-30

### Fixed
- **MQTT Command Routing**: Accept both `qdat` and `cooldat` as userapp identifiers in `set_reqToUserapp` envelope (was rejecting `cooldat` commands silently)
- **MQTT INVENTORY Snackbar**: Notification now shows after inventory actually starts, not before
- **MQTT PING Snackbar**: Suppressed UI notification for silent PING/PONG heartbeat commands

## [1.9.1] - 2026-03-30

### Added
- **MQTT PONG Heartbeat**: Sends a PONG message to the broker on connect and every 60 seconds for presence monitoring
- **MQTT PING Command**: Broker can send a PING command and receive a PONG response

### Changed
- **MQTT Scan Dedup**: Inventory tag data is throttled to max 1 publish per EPC per 5 seconds, preventing duplicate MQTT messages during continuous scanning

## [1.9.0] - 2026-03-29

### Added
- **MQTT Message Counters**: MQTT info dialog now displays messages sent (Out) and received (In) since last connection
- **MQTT Pending Message Queue**: Messages are queued when MQTT is enabled but disconnected, and auto-flushed on reconnect
- **MQTT Retry Button**: New retry action in MQTT info dialog to manually re-send pending messages
- **MQTT Icon States**: Three-color MQTT icon — green (connected), orange (pending messages), red (disconnected)
- **QR Scanner Upgrade**: CameraX-based scanner with tap-to-focus, zoom slider, and torch toggle (replaces ZXing IntentIntegrator)
- **QR Scanner Strings**: New localized strings for torch, tap-to-focus, camera permission across all 8 languages

### Changed
- **UI Color Theme**: Replaced blue cyan (#2196F3) with green (#1C7552) across entire app — colors, drawables, charts, dark mode
- **MQTT Read Logger**: Always publishes to MQTT when enabled, even if temporarily disconnected (messages queued for later delivery)
- **MQTT Client ID**: Unique client ID generated per connection to avoid broker conflicts
- **Website URL**: Updated from QDat.io/cooldat to QDat.io/qdatdroid

### Fixed
- **MQTT Offline Publishing**: Read Logger and scan data no longer silently dropped when MQTT is enabled but connection is temporarily lost

## [1.8.8] - 2026-03-25

### Added
- **MQTT GET_VERSION command**: Returns app version, device info, reader details, and GPS location
- **MQTT REBOOT command**: Remote reader reboot via MQTT
- **MQTT command UI feedback**: Progress dialog for remote READ operations, snackbar notifications for other MQTT commands
- **MQTT INFO enrichment**: Auto-reads TID bank and includes OPUS configuration when tag not in cache; quick scan fallback for tags not in inventory
- **MQTT READ tag config**: First batch includes parsed OPUS tag configuration (ARM time, log interval, temperature limits, LED settings)

### Changed
- **MQTT READ progressive publishing**: Samples are now published incrementally after each RFID read batch instead of waiting for all samples to be collected
- **MQTT READ data format**: Simplified payload structure — removed nested `parameters`/`progress` objects, uses flat `batch`/`complete`/`samples` format
- **MQTT logger publish format**: Aligned `publishLoggerDataWithSamples` with spec-compliant format (flat config on first batch, complete flag)
- **Rebranding**: Company name changed from PULR Technologies Inc. to Meerv Inc.
- **Rebranding**: Website URL changed from www.PULR.tech/cooldat to www.QDat.io/cooldat
- **Rebranding**: Support email changed from support@pulrtechnologies.com to support@qdat.io

### Fixed
- **MQTT READ TID parsing**: Corrected SSD override and sampling regime bit extraction (word 0x08/0x0F instead of word 0x0A)
- **MQTT STOP**: Now also cancels ongoing logger read operations
- **Read dialog progress bar**: Shows completion state instead of hiding on read finish

## [1.8.7] - 2026-03-17

### Fixed
- **MQTT Connection**: Handle Paho WebSocket "Already connected" race condition — verify actual client state before reporting success, retry with new client if not connected
- **MQTT Publish Throttling**: Force batch mode for all inventory scan publishes to prevent "Too many publishes in progress" errors from overwhelming Paho
- **MQTT Client ID**: Generate random client ID on each connection to avoid stale session conflicts

## [1.8.6] - 2026-03-14

### Changed
- **App Icon**: Updated launcher icons (ic_launcher, ic_launcher_round, ic_launcher_foreground, app_icon) to new QDat robot logo across all densities
- **QR Scanner**: Replaced ML Kit + CameraX (~20MB) with ZXing (~300KB) for QR code scanning, reducing APK size from ~30MB back to ~10MB

## [1.8.5] - 2026-03-13

### Added
- **MQTT Remote Commands**: New command subsystem (MqttCommandRouter, MqttCommandExecutor, MqttCommandParser, MqttResponsePublisher) supporting remote SCAN, READ, ARM, and STOP operations
- **MQTT QR Provisioning**: QR code scanner for automatic MQTT broker configuration
- **MQTT Inventory Publishing**: Tag data published to `devices/{UUID}/data` during inventory scans
- **Unified Topic Mode**: Structured topics (`devices/{UUID}/data`, `/command`, `/response`)
- **RE_SERIAL Reader Support**: Event-based tag reads and EPC filtering for RE_SERIAL readers

### Fixed
- **MQTT Connection Stability**: Use MqttCallbackExtended for auto-reconnect detection, add isConnecting guard to prevent double-connect races, properly close old clients before reconnecting
- **MQTT Thread Safety**: isConnected/isConnecting fields marked volatile
- **MQTT JSON Format**: SCAN data sent as array (matching backend ingester), batch command changed from SCAN_BATCH to SCAN
- **MQTT Service Integration**: MqttManager now injected into RfidService's RfidInventoryManager
- **ARM Dialog**: Replace blocking validation with Cancel/Continue warning dialog

### Changed
- **Rebranding**: QCooldatApiClient renamed to QDatApiClient

## [1.8.4] - 2026-03-01

### Fixed
- **OPUS Dialog**: Temperature averaging improvements, inventory error suppression, reset spec compliance
- **RFID Operations**: Improved lifecycle management and safe shutdown

## [1.8.3] - 2026-01-26

### Added
- **Product Flavors**: Three app variants with tiered feature sets
  - **Base**: Standard RFID inventory and tag reading
  - **Business**: + Write Memory, Export CSV
  - **Enterprise**: + MQTT publishing, Predictive API integration
- **Flavor Display**: Edition name shown in Splash Screen and About dialog
- **Dynamic Settings Tabs**: Application Settings shows MQTT/Predict API tabs only for Enterprise

### Changed
- **ApplicationId**: Each flavor has unique suffix (.base, .business, .enterprise)
- **App Name**: Variant-specific names (CoolDroid, CoolDroid Business, CoolDroid Enterprise)
- **Feature Gates**: BuildConfig flags control feature visibility at compile time

### Technical
- Gradle flavorDimensions with "tier" dimension
- BuildConfig fields: FEATURE_WRITE_MEMORY, FEATURE_EXPORT_CSV, FEATURE_MQTT, FEATURE_PREDICT_API
- Guard clauses in MqttManager for enterprise-only methods
- Dynamic tab count in ApplicationSettingsPagerAdapter

## [1.8.2] - 2026-01-26

### Added
- **ProGuard/R8 Obfuscation**: Release builds now use minifyEnabled and shrinkResources for smaller, optimized APKs
- **Reader Name Normalization**: User-friendly display names (EM45, RFD40, RFD90) in Reader Info and disconnect dialog

### Changed
- **ProGuard Rules**: Comprehensive rewrite with proper rules for Zebra SDK, Retrofit, Gson, MQTT, WorkManager, and AndroidX Lifecycle
- **Table View**: TID column removed from default order (TID now displayed inline below EPC)
- **ARM Dialog Logging**: Log section always visible with manual scroll control (removed auto-scroll and expand/collapse)
- **Column Reordering**: Improved adapter position handling for move up/down buttons

### Fixed
- **Table Column Order**: Column order now properly applied when switching to table view mode
- **View Mode Switching**: Proper notifyDataSetChanged() call when view mode changes

## [1.8.1] - 2026-01-24

### Added
- **Tag Locate Beep Setting**: New setting in Application Settings → General to enable/disable proximity beep
- Beep preference syncs bidirectionally between Application Settings and Tag Locate dialog
- **Safe Shutdown**: New `safeStopAllOperations()` method in RfidService for clean app exit

### Changed
- **New App Icon**: Updated to Cooldroid robot logo with RFID reader and snowflake
- App icon, splash screen, and about screen now use the new branding
- **RFID Lifecycle**: Improved operation state management in OPUS dialogs (ARM/Read)
- **Inventory Manager**: Enhanced stop/start handling with better state tracking

### Fixed
- **Tag Locate Beep**: Fixed beep not playing after first use (toneGenerator was released but not recreated)
- **App Exit Cleanup**: RFID operations now properly stopped when app is finishing (not on config changes)
- **TagsAdapter Filtering**: Improved filter logic for tag type and status badges

### Technical
- MainActivity: Call safeStopAllOperations() in onDestroy() only when isFinishing()
- RfidService: Added safeStopAllOperations() with inventory stop, TagAccess stop, and purgeTags
- OpusArmDialogHelper/OpusReadDialogHelper: Better operation state management
- ZebraRfidReaderAdapter: Minor improvements to reader operations

## [1.8.0] - 2026-01-23

### Added
- **TID Reading during Inventory**: New readEvent() mode reads TID bank alongside EPC in a single operation
- **Tag Type Filter**: Dynamic filter spinner that automatically populates with discovered tag types (OPUS, Impinj, NXP, Alien, etc.)
- **Tag Type Identification**: New TagTypeIdentifier utility class identifies 30+ tag types by TID prefix
- **Badge Click Filtering**: Clicking on status or tag type badges opens a dialog to apply that filter
- **Compact Filter UI**: New spinner layouts with reduced text size (11sp) for better space usage

### Changed
- **Temperature Chart Y-Axis**: Now dynamically adjusts to show ±5°C from actual data range instead of fixed -40°C to 85°C
- **TID/EPC Display**: Removed ellipsis ("...") truncation - now shows max 24 characters without trailing dots
- **Filter Spinners**: Redesigned with compact 32dp height and custom background styling
- **Filter Layout**: Removed "Filters:" label to save space, reduced margins between spinners

### Fixed
- **OPERATION_IN_PROGRESS Error**: Fixed race condition when opening Read Logger/ARM dialogs during readEvent inventory
- **readEvent Auto-restart**: Properly configured stop trigger with 1-hour duration to prevent immediate INVENTORY_STOP_EVENT
- **PreFilters for OPUS**: Ensured OPUS measurement filters are applied before periodic refresh in ARM/Read dialogs

### Technical
- ZebraRfidReaderAdapter: Added setTidReadingEnabled() for readEvent mode toggle
- RfidInventoryManager: Added trigger configuration for continuous readEvent operation
- RfidService: Added INVENTORY_STOP_EVENT handling with auto-restart logic
- TagsAdapter: Added TagTypeFilterListener and BadgeClickListener interfaces
- ChartHelper: Dynamic Y-axis calculation from entry data

## [1.7.7.1] - 2026-01-23

### Added
- Non-OPUS tag detection in ARM dialog with warning message
- Progressive TID reading for non-OPUS tags (reads maximum available TID)

### Changed
- ARM dialog now shows "⚠ NOT OPUS" state and disables ARM button for non-OPUS tags
- Configuration and Logging tabs hidden for non-OPUS tags
- TID display shows up to 24 characters for any tag type

## [1.7.7] - 2026-01-23

### Added
- Footer feedback dialog with options to visit website or send email
- Localized feedback strings for all 8 supported languages
- Sortable columns in Read Logger table (click headers to sort by #, Timestamp, or Temperature)
- Beep toggle checkbox in Tag Locate dialog
- Connection retry logic (up to 3 attempts) with BATCHMODE_IN_PROGRESS handling

### Changed
- Footer text changed from "www.PULR.tech/cooldat" to "Give us your feedback"
- Recalibrated distance estimator for Zebra handheld readers (RFD40/RFD90)
- Hidden unreliable metrics in Tag Locate dialog (distance, phase, direction)

### Removed
- PC Word display card from ARM dialog

## [1.7.6.1] - 2026-01-22

### Changed
- Auto-connect only applies at app startup, manual Connect always shows reader selection dialog

## [1.7.6] - 2026-01-22

### Fixed
- Display detailed Zebra SDK error messages on connection failure (vendor message, status description)

## [1.7.5] - 2026-01-22

### Added
- Bluetooth disconnection detection for RFID reader
- DISCONNECTED_UNEXPECTEDLY connection state for unexpected reader loss
- Reader MAC address tracking for precise disconnect detection

### Fixed
- Android 13+ compatibility for Bluetooth ACL_DISCONNECTED events
- RfidService state cleanup on unexpected Bluetooth disconnection
- Reader icon click now correctly shows connect dialog after disconnection

## [1.7.4] - 2026-01-22

### Added
- Accordion-style profile management in OPUS Arming Settings
- Each ARM profile now expands to show all configuration fields inline
- Per-profile Apply and Delete buttons within accordion cards
- Profile rename functionality via menu

### Changed
- Profiles are now managed exclusively in Application Settings
- Remove "Save as Profile" button from ARM dialog
- Only one profile can be expanded at a time (single-expand accordion)
- Auto-expand default profile on settings screen load

### Fixed
- Preserve expanded profile state across device rotation

## [1.7.3] - 2026-01-21

### Changed
- Remove LED Mode dropdown from ARM settings (auto-select based on Enable LED checkbox)
- ARM dialog: Show "--" for battery voltage when OC RSSI is 0

### Fixed
- ARM dialog: Force stop and purge pending operations when dialog is closed

## [1.7.2] - 2026-01-21

### Added
- Dual temperature column display (°C and °F) in Read Logger table when user preference is "Both"
- Temperature threshold-based chart coloring (red dots for out-of-range values)
- Alarm violation details in Status tab (count, first/last violation timestamps)
- Status tab Refresh button in Read Logger dialog

### Changed
- Force RFID RF Mode to Miller 2 (M2) with PIE 2000 on reader connection
- Remove RF Mode option from RFID Settings - Antenna screen
- Alarm status now shown via red EPC text instead of alarm chips
- Simplified alarm display in Grid and Table views
- Hide Export CSV and Analyze buttons in Read Logger (for future use)
- Hide PC Word card, Pull Down Resistance, and UTC seconds rows in Configuration tab

### Fixed
- Dynamic RF Mode selection queries reader RF Mode table for optimal mode

## [1.7.1] - 2026-01-20

### Fixed
- Read Logger: Sample count now re-reads from TID when Read button is clicked
- Read Logger: Local Time checkbox now properly refreshes table data with correct timezone
- ARM Logger: LED timing values are now properly passed through configuration chain (was hardcoded to 16/16)

### Changed
- ARM Logger: Detailed Log section expands to 80% window height when expanded
- Read Logger: Configuration tab uses table-based layout for better readability

### Added
- ARM Logger: Step-by-step progress indicators for ARM operation
- Configuration section icons (logging, temperature, features, arm time)
- ARM step status icons (pending, in progress, success, error)
- Updated translations for new UI strings across all 8 languages

## [1.7.0] - 2026-01-19

### Fixed
- Correct LED configuration bit positions in OpusConfiguration
- Remove FingerSpot display from ARM operation logging output

### Added
- Enhanced tag locate with distance, phase, and direction metrics
- MainActivityViewModel for lifecycle-aware UI state management
- Configurable tag deduplication threshold setting
- MQTT batch publishing mode

### Changed
- Extract OpusParser into focused decoder classes
- Extract BaseOpusDialogHelper from dialog helpers
- DiffUtil support for TagsAdapter for efficient RecyclerView updates
- Code quality improvements: handler cleanup and deduplication

## [1.6.1] - 2026-01-15

### Fixed
- Improve inventory restart reliability and UI fixes
- ARM dialog improvements and invalid PC handling

### Added
- Logging tab to ARM dialog with detailed operation logs
- Distinct colors for Start/Stop button states
- Custom footer buttons replacing BottomNavigationView
- Alarm Delay settings for temperature limits

### Changed
- Replace all Toast with SnackbarHelper
- Position Snackbar above footer bar and center on screen
- Remove license UI and RTC Reset feature
- Strip trailing zeros from EPC display
- Disable AntiTamper feature across application

## [1.4.2] - 2026-01-10

### Fixed
- Initialize GPS manager properly and add altitude display
- Sync inventory state when stop is called on already-stopped inventory

### Added
- Trigger button support in RfidService
- Configurable tag timeout setting
- Preparation dialog shows immediately on tag action clicks

### Changed
- Refactor license system to new API endpoints
- Refactor MQTT payloads to standardized format
- Improve tag item layout with badges and single-line action buttons

## [1.4.1] - 2026-01-05

### Fixed
- Table navigation now scrolls exactly 10 items
- Close power slider popup when Apply is clicked
- Wait for inventory to fully stop before enabling power slider

### Added
- Apply button to power slider popup
- Read Logger dialog refresh functionality

### Changed
- Improve ARM procedure, power slider, and UI enhancements

## [1.4.0] - 2026-01-02

### Added
- Table view and Analyze button functionality to Logger Data
- Status tab in Read Logger dialog
- Disconnect confirmation dialog

### Fixed
- Tag display and stop button functionality in RfidService integration
- Inventory control improvements

### Changed
- Reorganize Read Logger dialog with improved UI consistency

## [1.3.0] - 2025-12-20

### Added
- Chinese (Simplified) language support
- Delayed start and LED timing controls to OPUS ARM settings
- RfidService for lifecycle-independent RFID operations

### Fixed
- Preserve RFID reader connection during theme toggle
- Update ReaderInfoActivity to use RfidService

### Changed
- Implement RfidService lifecycle management
- Improved ARM terminology across all translations

## [1.2.2] - 2025-12-15

### Added
- German translations
- Sortable columns and reorderable layout for table view
- Inventory pause/resume with progress dialogs

### Changed
- Unify Table/Grid data sources
- Enhanced table view with fixed EPC column

## [1.2.1] - 2025-12-10

### Fixed
- RFID tag reading reliability and performance improvements
- Add RECEIVER_NOT_EXPORTED flag for Android 13+ compatibility

### Added
- Detailed logging for TID capture from inventory
- License diagnostics
- Radar animations and dual-axis chart

### Changed
- UI theme adaptation improvements
- Quick Scan icon update

## [1.2.0] - 2025-12-05

### Added
- ARM tag validation and refresh button
- Tag monitoring during ARM procedure
- Radar view toggle in tag locator
- Tag location feature with radar-style UI
- Dual-mode search with navigation and highlighting

### Fixed
- RFID reader selection dialog visibility
- Radar view labels showing negative RSSI values
- Interval conversion in ARM procedure

## [1.1.0] - 2025-11-25

### Added
- Temperature unit selection in Application Settings
- Flexible memory bank configuration
- Status button and quick scan with alarm detection
- Licensing system

### Fixed
- PC visibility and dynamic temperature range display
- UI responsiveness during inventory operations

### Changed
- Extract Reset Tag dialog
- Improve Read Logger UI and ARM dialog UX
- Quick scan mode and StoredPC handling improvements

## [1.0.4] - 2025-11-15

### Added
- Dark mode support with theme toggle
- Action buttons to tag items
- Translations for OPUS settings, MQTT, and API messages

### Fixed
- Toolbar menu popup visibility in dark mode
- Cancel button visibility in reader connection dialog

## [1.0.3] - 2025-11-10

### Added
- GPS enable/disable option in Application Settings
- Offline GPS mapping with OSMDroid
- MQTT integration with enhanced status indicators
- Multilingual support (EN/FR/ES)

### Changed
- Simplify RFID Settings
- Enhanced reader connection flow

## [1.0.2] - 2025-11-05

### Added
- ARM logger with progress tracking and retry logic
- ARMED and STARTED states to Read Logger action

### Fixed
- Initialize tid0x08to0x1F array in Configuration JSONObject constructor

### Changed
- Refactor ARM configuration to use OpusParser for correct OPUS TID format

## [1.0.1] - 2025-11-01

### Added
- 5-second throttling to prevent duplicate tag inventory
- ARM Logger dialog for OPUS tags in Sleep/Standby/BAP states
- Multi-reader selection support with preference persistence
- QCooldat API integration

### Fixed
- Tags list scrolling and splash screen issues
- PreFilter configuration improvements

### Changed
- Optimize RFID inventory
- Improve tag list scrolling performance
- Configure immediate triggers

## [1.0.0] - 2025-10-25

### Added
- Initial release of CoolDat Android RFID application
- OPUS IC temperature logger tag support
- Zebra RFID reader integration
- Temperature monitoring and logging
- GPS location capture
- Real-time tag inventory
